├── .github ├── ISSUE_TEMPLATE │ ├── 01-maintainer.md │ └── config.yml ├── dependabot.yml └── workflows │ ├── gettext.yml │ ├── image.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── build.rs ├── dbus-1 ├── de.swsnr.turnon.service └── org.gnome.ShellSearchProvider2.xml ├── de.swsnr.turnon.Source.svg ├── de.swsnr.turnon.desktop.in ├── de.swsnr.turnon.search-provider.ini ├── deny.toml ├── flatpak ├── de.swsnr.turnon.Devel.yaml └── de.swsnr.turnon.yaml ├── po ├── LINGUAS ├── de.po ├── de.swsnr.turnon.pot ├── es.po ├── et.po ├── fi.po ├── he.po ├── it.po └── nl.po ├── resources ├── de.swsnr.turnon.metainfo.xml.in ├── gtk │ ├── help-overlay.blp │ └── help-overlay.ui ├── icons │ ├── scalable │ │ ├── actions │ │ │ ├── checkmark-symbolic.svg │ │ │ ├── computer-symbolic.svg │ │ │ ├── edit-symbolic.svg │ │ │ ├── menu-large-symbolic.svg │ │ │ ├── plus-large-symbolic.svg │ │ │ ├── sonar-symbolic.svg │ │ │ ├── user-trash-symbolic.svg │ │ │ ├── warning-outline-symbolic.svg │ │ │ └── waves-and-screen-symbolic.svg │ │ └── apps │ │ │ ├── de.swsnr.pictureoftheday.svg │ │ │ ├── de.swsnr.turnon.Devel.svg │ │ │ └── de.swsnr.turnon.svg │ └── symbolic │ │ └── apps │ │ └── de.swsnr.turnon-symbolic.svg ├── resources.gresource.xml ├── style.css └── ui │ ├── device-row.blp │ ├── device-row.ui │ ├── edit-device-dialog.blp │ ├── edit-device-dialog.ui │ ├── turnon-application-window.blp │ ├── turnon-application-window.ui │ ├── validation-indicator.blp │ └── validation-indicator.ui ├── schemas └── de.swsnr.turnon.gschema.xml ├── screenshots ├── arp ├── devices.json ├── edit-device.png ├── list-of-devices.png ├── list-of-discovered-devices.png ├── run-for-screenshot.bash └── start-page.png ├── scripts ├── build-social-image.bash └── prerelease.py ├── social-image.png ├── src ├── app.rs ├── app │ ├── commandline.rs │ ├── debuginfo.rs │ ├── model.rs │ ├── model │ │ ├── device.rs │ │ ├── device_discovery.rs │ │ └── devices.rs │ ├── searchprovider.rs │ ├── storage.rs │ ├── widgets.rs │ └── widgets │ │ ├── application_window.rs │ │ ├── device_row.rs │ │ ├── edit_device_dialog.rs │ │ └── validation_indicator.rs ├── config.rs ├── dbus.rs ├── dbus │ └── searchprovider2.rs ├── futures.rs ├── gettext.rs ├── main.rs ├── net.rs └── net │ ├── arpcache.rs │ ├── http.rs │ ├── macaddr.rs │ ├── monitor.rs │ ├── ping.rs │ └── wol.rs └── supply-chain ├── audits.toml ├── config.toml └── imports.lock /.github/ISSUE_TEMPLATE/01-maintainer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Create issue 3 | about: |- 4 | As a maintainer, create an actionable issue here. 5 | --- 6 | 7 | **PLEASE USE GITHUB DISCUSSIONS FOR BUG REPORTS AND FEATURE REQUESTS.** 8 | 9 | See . 10 | 11 | As a maintainer, I reserve Github issues for fully specified actionable issues 12 | which are ready to be worked on, and which I intend to address sooner or later. 13 | 14 | Please do not create issues for bug reports and feature requests; refer to 15 | Github discussions for these. If a bug is confirmed and reproduced, or a 16 | feature request fully specified, and if I then intend to actually work on it, 17 | I'll open a corresponding issue. 18 | 19 | I'll close any other issues without warning or explanation. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support 4 | url: https://github.com/swsnr/turnon/discussions/categories/q-a 5 | about: Please ask and answer questions here. 6 | - name: Ideas and Issues 7 | url: https://github.com/swsnr/turnon/discussions/categories/ideas-issues 8 | about: Please report bugs, issues, and feature reports here. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Check for updates of actions every month. 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | assignees: [swsnr] 9 | # We deliberately do not check crate updates, because it's way too much, 10 | # and cargo update exists. With cargo-vet we'd not be able to merge these 11 | # PRs anyway. 12 | # 13 | # For security updates we get Github notifications anyway. 14 | -------------------------------------------------------------------------------- /.github/workflows/gettext.yml: -------------------------------------------------------------------------------- 1 | name: gettext 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["main"] 7 | paths: 8 | - src/**/*.rs 9 | - resources/**/*.blp 10 | - resources/de.swsnr.turnon.metainfo.xml.in 11 | - de.swsnr.turnon.desktop.in 12 | - Makefile 13 | - .github/workflows/gettext.yml 14 | 15 | jobs: 16 | xgettext: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | pull-requests: write 21 | container: 22 | image: ghcr.io/swsnr/turnon/ci:main 23 | steps: 24 | - uses: actions/checkout@v4 25 | - run: make pot 26 | # Create a pull request to update the messages file 27 | - uses: peter-evans/create-pull-request@v7 28 | with: 29 | commit-message: | 30 | Update messages 31 | 32 | Github bot runs make pot :v: 33 | branch: workflow/update-messages 34 | base: main 35 | sign-commits: true 36 | delete-branch: true 37 | title: "Update messages" 38 | body: "Github bot runs make pot :v:" 39 | assignees: swsnr 40 | draft: true 41 | add-paths: po/de.swsnr.turnon.pot 42 | -------------------------------------------------------------------------------- /.github/workflows/image.yml: -------------------------------------------------------------------------------- 1 | name: CI base image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths: 8 | - "Dockerfile" 9 | - ".github/workflows/image.yml" 10 | pull_request: 11 | paths: 12 | - "Dockerfile" 13 | - ".github/workflows/image.yml" 14 | workflow_dispatch: 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }}/ci 19 | 20 | jobs: 21 | build-and-push-image: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | attestations: write 27 | id-token: write 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: docker/login-action@v3 31 | if: github.event_name != 'pull_request' 32 | with: 33 | registry: ${{ env.REGISTRY }} 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Docker meta 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 41 | - uses: docker/build-push-action@v6 42 | id: push 43 | with: 44 | push: ${{ github.event_name != 'pull_request' }} 45 | context: . 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | - name: Generate artifact attestation 49 | uses: actions/attest-build-provenance@v2 50 | with: 51 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 52 | subject-digest: ${{ steps.push.outputs.digest }} 53 | push-to-registry: ${{ github.event_name != 'pull_request' }} 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: v* 6 | 7 | permissions: read-all 8 | 9 | jobs: 10 | prepare-release-notes: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: appstreamcli metainfo-to-news 15 | uses: docker://ghcr.io/swsnr/turnon/ci:main 16 | with: 17 | args: appstreamcli metainfo-to-news resources/de.swsnr.turnon.metainfo.xml.in news.yaml 18 | - run: yq eval-all '[.]' -oj news.yaml > news.json 19 | - run: jq -r --arg tag "${TAGNAME}" '.[] | select(.Version == ($tag | ltrimstr("v"))) | .Description | tostring' > relnotes.md < news.json 20 | env: 21 | TAGNAME: '${{ github.ref_name }}' 22 | - run: cat relnotes.md 23 | - uses: actions/upload-artifact@v4 24 | with: 25 | name: relnotes 26 | path: relnotes.md 27 | 28 | vendor-dependencies: 29 | permissions: 30 | id-token: write 31 | contents: read 32 | attestations: write 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: dtolnay/rust-toolchain@stable 37 | - run: cargo --version 38 | - run: tar --version 39 | - run: zstd --version 40 | - run: echo "${GITHUB_SHA}" 41 | # Generate a reproducible vendor bundle 42 | - run: env LC_ALL=C TZ=UTC0 echo "timestamp=$(git show --quiet --date='format-local:%Y-%m-%dT%H:%M:%SZ' --format="%cd" "${GITHUB_SHA}")" >> "$GITHUB_OUTPUT" 43 | id: timestamp 44 | - run: cargo vendor --locked 45 | - run: env LC_ALL=C tar --numeric-owner --owner 0 --group 0 --sort name --mode='go+u,go-w' --format=posix --pax-option=exthdr.name=%d/PaxHeaders/%f --pax-option=delete=atime,delete=ctime --mtime="${{ steps.timestamp.outputs.timestamp }}" -c -f turnon-${{ github.ref_name }}-vendor.tar.zst --zstd vendor 46 | - uses: actions/attest-build-provenance@v2 47 | with: 48 | subject-path: turnon-${{ github.ref_name }}-vendor.tar.zst 49 | - uses: actions/upload-artifact@v4 50 | with: 51 | name: turnon-${{ github.ref_name }}-vendor.tar.zst 52 | path: turnon-${{ github.ref_name }}-vendor.tar.zst 53 | 54 | git-archive: 55 | permissions: 56 | id-token: write 57 | contents: read 58 | attestations: write 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - run: env LC_ALL=C TZ=UTC0 git archive --format tar --prefix 'turnon-${{ github.ref_name }}/' --output 'turnon-${{ github.ref_name }}.tar' "${{ github.sha }}" 63 | - run: zstd 'turnon-${{ github.ref_name }}.tar' 64 | - uses: actions/attest-build-provenance@v2 65 | with: 66 | subject-path: 'turnon-${{ github.ref_name }}.tar.zst' 67 | - uses: actions/upload-artifact@v4 68 | with: 69 | name: turnon-${{ github.ref_name }}.tar.zst 70 | path: turnon-${{ github.ref_name }}.tar.zst 71 | 72 | create-release: 73 | runs-on: ubuntu-latest 74 | needs: [prepare-release-notes, git-archive, vendor-dependencies] 75 | permissions: 76 | contents: write 77 | steps: 78 | - uses: actions/download-artifact@v4 79 | with: 80 | path: ./artifacts 81 | merge-multiple: true 82 | - uses: softprops/action-gh-release@v2 83 | with: 84 | body_path: ./artifacts/relnotes.md 85 | make_latest: true 86 | files: | 87 | ./artifacts/*.tar.* 88 | 89 | # Update flatpak manifest 90 | update-manifest: 91 | runs-on: ubuntu-latest 92 | needs: [git-archive, vendor-dependencies] 93 | permissions: 94 | contents: write 95 | pull-requests: write 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: actions/download-artifact@v4 99 | with: 100 | path: ./artifacts 101 | merge-multiple: true 102 | - run: echo ARCHIVE_SHA512="$(sha512sum artifacts/turnon-${{ github.ref_name }}.tar.zst | cut -d' ' -f1)" >> "$GITHUB_ENV" 103 | - run: echo VENDOR_SHA512="$(sha512sum artifacts/turnon-${{ github.ref_name }}-vendor.tar.zst | cut -d' ' -f1)" >> "$GITHUB_ENV" 104 | - run: rm -rf artifacts 105 | - run: yq eval -i '.modules.[0].sources.[0].url = "https://github.com/swsnr/turnon/releases/download/$GITHUB_REF_NAME/turnon-$GITHUB_REF_NAME.tar.zst"' flatpak/de.swsnr.turnon.yaml 106 | - run: yq eval -i '.modules.[0].sources.[0].sha512 = "$ARCHIVE_SHA512"' flatpak/de.swsnr.turnon.yaml 107 | - run: yq eval -i '.modules.[0].sources.[1].url = "https://github.com/swsnr/turnon/releases/download/$GITHUB_REF_NAME/turnon-$GITHUB_REF_NAME-vendor.tar.zst"' flatpak/de.swsnr.turnon.yaml 108 | - run: yq eval -i '.modules.[0].sources.[1].sha512 = "$VENDOR_SHA512"' flatpak/de.swsnr.turnon.yaml 109 | - run: yq eval -i '(.. | select(tag == "!!str")) |= envsubst' flatpak/de.swsnr.turnon.yaml 110 | # A little sanity check 111 | - run: git diff 112 | # Create a pull request to update the manifest on main 113 | - uses: peter-evans/create-pull-request@v7 114 | with: 115 | commit-message: "Update flatpak manifest for ${{ github.ref_name }}" 116 | branch: workflow/update-flatpak-manifest 117 | base: main 118 | sign-commits: true 119 | delete-branch: true 120 | title: "Update flatpak manifest for ${{ github.ref_name }}" 121 | body: | 122 | Update flatpak manifest for release ${{ github.ref_name }}. 123 | 124 | After merging, manually dispatch the [sync workflow in the flathub repo](https://github.com/flathub/de.swsnr.turnon/actions/workflows/sync.yaml) to update the flathub manifest. 125 | assignees: swsnr 126 | draft: always-true 127 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ["main", "devel"] 6 | pull_request: 7 | branches: ["main"] 8 | # Run this workflow also when a PR becomes ready for review; this enables us 9 | # to open automated PRs as draft, and then explicitly make them ready for 10 | # review manually to trigger the workflow. 11 | types: [opened, reopened, synchronize, ready_for_review] 12 | 13 | env: 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | flatpak: 18 | name: Lint flatpak manifest and metadata 19 | runs-on: ubuntu-latest 20 | container: 21 | image: ghcr.io/flathub/flatpak-builder-lint:latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: flatpak-builder-lint appstream resources/de.swsnr.turnon.metainfo.xml.in 25 | - run: flatpak-builder-lint manifest flatpak/de.swsnr.turnon.yaml 26 | 27 | # See https://mozilla.github.io/cargo-vet/configuring-ci.html 28 | cargo-vet: 29 | name: Vet Dependencies 30 | runs-on: ubuntu-latest 31 | env: 32 | CARGO_VET_VERSION: 0.10.0 33 | # Only consider Linux dependencies, as that's all I care for. 34 | # Seems to be unofficial, see https://github.com/mozilla/cargo-vet/issues/579, but works 35 | CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu 36 | steps: 37 | - uses: actions/checkout@master 38 | - uses: dtolnay/rust-toolchain@stable 39 | - uses: actions/cache@v4 40 | with: 41 | path: ${{ runner.tool_cache }}/cargo-vet 42 | key: cargo-vet-bin-${{ env.CARGO_VET_VERSION }} 43 | - run: echo "${{ runner.tool_cache }}/cargo-vet/bin" >> $GITHUB_PATH 44 | - run: cargo install --root ${{ runner.tool_cache }}/cargo-vet --version ${{ env.CARGO_VET_VERSION }} cargo-vet 45 | - run: cargo vet --locked 46 | 47 | build: 48 | name: Build & lint 49 | runs-on: ubuntu-latest 50 | container: 51 | # Base image for CI 52 | image: ghcr.io/swsnr/turnon/ci:main 53 | env: 54 | # Skip blueprint compilation because the gtk4-rs container lacks the 55 | # typelib files required for blueprint to resolve imports. 56 | SKIP_BLUEPRINT: 1 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: dtolnay/rust-toolchain@stable 60 | id: toolchain 61 | with: 62 | components: rustfmt, clippy 63 | # See https://github.com/actions/cache/blob/main/examples.md#rust---cargo 64 | - uses: actions/cache@v4 65 | with: 66 | path: | 67 | ~/.cargo/bin/ 68 | ~/.cargo/registry/index/ 69 | ~/.cargo/registry/cache/ 70 | ~/.cargo/git/db/ 71 | target/ 72 | key: ${{ runner.os }}-rust-${{ steps.toolchain.outputs.cachekey }}-cargo-${{ hashFiles('**/Cargo.lock') }} 73 | - run: cargo fmt --check 74 | - run: blueprint-compiler format resources/**/*.blp 75 | # Make the glob work 76 | shell: bash 77 | - run: cargo build 78 | - run: cargo clippy --all-targets 79 | - run: cargo test 80 | - name: cargo deny check 81 | uses: EmbarkStudios/cargo-deny-action@v2 82 | with: 83 | rust-version: stable 84 | - run: appstreamcli validate --explain resources/de.swsnr.turnon.metainfo.xml 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | /.vscode/ 4 | 5 | /.flatpak-builder/ 6 | /builddir/ 7 | /repo/ 8 | 9 | # Translated data files 10 | /de.swsnr.turnon.desktop 11 | /resources/de.swsnr.turnon.metainfo.xml 12 | 13 | # Compiled gettext catalogs 14 | /po/*.mo 15 | 16 | # Compiled schemas 17 | /schemas/gschemas.compiled 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "turnon" 3 | description = "Turn on devices in your network" 4 | homepage = "https://github.com/swsnr/turnon" 5 | repository = "https://github.com/swsnr/turnon" 6 | license = "MPL-2.0" 7 | authors = ["Sebastian Wiesner "] 8 | # Our version number. Since semver doesn't make much sense for UI applications 9 | # which have no API we repurpose the version numer as follows: 10 | # 11 | # - major: Major new features or major changes to the UI, which may break the app for some users. 12 | # - minor: User-visible features or bugfixes. 13 | # - patch: Translation updates. 14 | # 15 | # Major and minor releases get release notes, but patch releases do not; this 16 | # enables us to ship updates to translations whenever translators contributed 17 | # new languages or made major updates, while still providing meaningful release 18 | # notes for the last functional changes. 19 | version = "2.6.3" 20 | edition = "2024" 21 | publish = false 22 | build = "build.rs" 23 | 24 | [dependencies] 25 | adw = { package = "libadwaita", version = "0.7.0", features = ["v1_7"] } 26 | async-channel = "2.3.1" 27 | futures-util = { version = "0.3.31" } 28 | glib = { version = "0.20.9", features = ["log", "log_macros", "v2_84"] } 29 | gtk = { package = "gtk4", version = "0.9.6", features = ["v4_18", "gnome_47"] } 30 | log = "0.4.22" 31 | macaddr = "1.0.1" 32 | serde = { version = "1.0.210", features = ["derive"] } 33 | serde_json = "1.0.128" 34 | bitflags = "2.6.0" 35 | semver = "1.0.24" 36 | libc = "0.2.161" 37 | wol = "0.3.0" 38 | 39 | [build-dependencies] 40 | glob = "0.3.1" 41 | 42 | [package.metadata.release] 43 | pre-release-commit-message = "Release {{version}}" 44 | tag-message = "Turn On {{tag_name}}" 45 | publish = false 46 | verify = false 47 | push = false 48 | sign-tag = true 49 | sign-commit = true 50 | pre-release-hook = ["scripts/prerelease.py", "{{tag_name}}", "{{date}}"] 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/fedora:42 2 | LABEL org.opencontainers.image.description "CI image for de.swsnr.turnon" 3 | 4 | RUN dnf install -y --setopt=install_weak_deps=False blueprint-compiler libadwaita-devel gcc pkgconf git gettext make appstream && \ 5 | dnf clean all && rm -rf /var/cache/yum 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # The app ID to use. 2 | # 3 | # Use de.swsnr.turnon for the standard app ID, and de.swsnr.turnon.Devel to 4 | # build a nightly snapshot. Other values are not supported. 5 | APPID = de.swsnr.turnon 6 | # The destination prefix to install files to. Combines traditional DESTDIR and 7 | # PREFIX variables; turnon does not encode the prefix into its binary and thus 8 | # does not need to distinguish between the prefix and the destdir. 9 | DESTPREFIX = /app 10 | # Installation directory for locale files. 11 | LOCALEDIR = $(DESTPREFIX)/share/locale/ 12 | 13 | GIT_DESCRIBE = $(shell git describe) 14 | 15 | BLUEPRINTS = $(wildcard ui/*.blp) 16 | CATALOGS = $(wildcard po/*.po) 17 | 18 | XGETTEXT_OPTS = \ 19 | --package-name=$(APPID) \ 20 | --foreign-user --copyright-holder "Sebastian Wiesner " \ 21 | --sort-by-file --from-code=UTF-8 --add-comments 22 | 23 | # Extract the message template from all source files. 24 | # 25 | # You typically do not need to run this manually: The gettext Github workflow 26 | # watches for changes to relevant source files, runs this target, and opens a 27 | # pull request with the corresponding changes. 28 | # 29 | # When changing the set of files taken into account for xgettext also update the 30 | # paths list in the gettext.yml workflow to make sure that updates to these 31 | # files are caught by the gettext workflows. 32 | # 33 | # We strip the POT-Creation-Date from the resulting POT because xgettext bumps 34 | # it everytime regardless if anything else changed, and this just generates 35 | # needless diffs. 36 | .PHONY: pot 37 | pot: 38 | find src -name '*.rs' > po/POTFILES.rs 39 | find resources/ -name '*.blp' > po/POTFILES.blp 40 | xgettext $(XGETTEXT_OPTS) --language=C --keyword=dpgettext2:2c,3 --files-from=po/POTFILES.rs --output=po/de.swsnr.turnon.rs.pot 41 | xgettext $(XGETTEXT_OPTS) --language=C --keyword=_ --keyword=C_:1c,2 --files-from=po/POTFILES.blp --output=po/de.swsnr.turnon.blp.pot 42 | xgettext $(XGETTEXT_OPTS) --output=po/de.swsnr.turnon.pot \ 43 | po/de.swsnr.turnon.rs.pot po/de.swsnr.turnon.blp.pot \ 44 | resources/de.swsnr.turnon.metainfo.xml.in de.swsnr.turnon.desktop.in 45 | rm -f po/POTFILES* po/de.swsnr.turnon.rs.pot po/de.swsnr.turnon.blp.pot 46 | sed -i /POT-Creation-Date/d po/de.swsnr.turnon.pot 47 | 48 | po/%.mo: po/%.po 49 | msgfmt --output-file $@ --check $< 50 | 51 | # Compile binary message catalogs from message catalogs 52 | .PHONY: msgfmt 53 | msgfmt: $(addsuffix .mo,$(basename $(CATALOGS))) 54 | 55 | $(LOCALEDIR)/%/LC_MESSAGES/$(APPID).mo: po/%.mo 56 | install -Dpm0644 $< $@ 57 | 58 | # Install compiled locale message catalogs. 59 | .PHONY: install-locale 60 | install-locale: $(addprefix $(LOCALEDIR)/,$(addsuffix /LC_MESSAGES/$(APPID).mo,$(notdir $(basename $(CATALOGS))))) 61 | 62 | # Install Turn On into $DESTPREFIX using $APPID. 63 | # 64 | # You must run cargo build --release before invoking this target! 65 | .PHONY: install 66 | install: install-locale 67 | install -Dm0755 target/release/turnon $(DESTPREFIX)/bin/$(APPID) 68 | install -Dm0644 -t $(DESTPREFIX)/share/icons/hicolor/scalable/apps/ resources/icons/scalable/apps/$(APPID).svg 69 | install -Dm0644 resources/icons/symbolic/apps/de.swsnr.turnon-symbolic.svg \ 70 | $(DESTPREFIX)/share/icons/hicolor/symbolic/apps/$(APPID)-symbolic.svg 71 | install -Dm0644 de.swsnr.turnon.desktop $(DESTPREFIX)/share/applications/$(APPID).desktop 72 | install -Dm0644 resources/de.swsnr.turnon.metainfo.xml $(DESTPREFIX)/share/metainfo/$(APPID).metainfo.xml 73 | install -Dm0644 dbus-1/de.swsnr.turnon.service $(DESTPREFIX)/share/dbus-1/services/$(APPID).service 74 | install -Dm0644 de.swsnr.turnon.search-provider.ini $(DESTPREFIX)/share/gnome-shell/search-providers/$(APPID).search-provider.ini 75 | install -Dm0644 schemas/de.swsnr.turnon.gschema.xml $(DESTPREFIX)/share/glib-2.0/schemas/$(APPID).gschema.xml 76 | 77 | # Patch the current git describe version into Turn On. 78 | .PHONY: patch-git-version 79 | patch-git-version: 80 | sed -Ei 's/^version = "([^"]+)"/version = "\1+$(GIT_DESCRIBE)"/' Cargo.toml 81 | cargo update -p turnon 82 | 83 | # Patch the app ID to use APPID in various files 84 | .PHONY: patch-appid 85 | patch-appid: 86 | sed -i '/$(APPID)/! s/de\.swsnr\.turnon/$(APPID)/g' \ 87 | src/config.rs \ 88 | resources/de.swsnr.turnon.metainfo.xml.in de.swsnr.turnon.desktop.in \ 89 | dbus-1/de.swsnr.turnon.service de.swsnr.turnon.search-provider.ini \ 90 | schemas/de.swsnr.turnon.gschema.xml 91 | 92 | # Remove compiled message catalogs and other generated files, and flatpak 93 | # things 94 | .PHONY: clean 95 | clean: 96 | rm -fr po/*.mo builddir repo .flatpak-builder 97 | 98 | # Build a development flatpak without sandbox. 99 | .PHONY: flatpak-devel 100 | flatpak-devel: 101 | flatpak run org.flatpak.Builder --force-clean --user --install \ 102 | --install-deps-from=flathub --repo=repo \ 103 | builddir flatpak/de.swsnr.turnon.Devel.yaml 104 | 105 | # Build a regular flatpak (sandboxed build) 106 | .PHONY: flatpak 107 | flatpak: 108 | flatpak run org.flatpak.Builder --force-clean --sandbox --user --install \ 109 | --install-deps-from=flathub --ccache \ 110 | --mirror-screenshots-url=https://dl.flathub.org/media/ --repo=repo \ 111 | builddir flatpak/de.swsnr.turnon.yaml 112 | 113 | .PHONY: flatpak-lint-manifest 114 | flatpak-lint-manifest: 115 | flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ 116 | manifest flatpak/de.swsnr.turnon.yaml 117 | 118 | .PHONY: flatpak-lint-repo 119 | flatpak-lint-repo: 120 | flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Moved to 2 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::io::{Error, ErrorKind, Result}; 8 | use std::path::{Path, PathBuf}; 9 | use std::process::{Command, Stdio}; 10 | 11 | fn glob_io(pattern: &str) -> Result> { 12 | glob::glob(pattern) 13 | .map_err(|err| Error::new(ErrorKind::Other, err))? 14 | .map(|item| item.map_err(|err| Error::new(ErrorKind::Other, err))) 15 | .collect::>>() 16 | } 17 | 18 | trait CommandExt { 19 | fn checked(&mut self); 20 | } 21 | 22 | impl CommandExt for Command { 23 | fn checked(&mut self) { 24 | let status = self.status().unwrap(); 25 | if !status.success() { 26 | panic!("Command {:?} failed with status {status}", self); 27 | } 28 | } 29 | } 30 | 31 | /// Compile all blueprint files. 32 | fn compile_blueprint() -> Vec { 33 | let blueprint_files = glob_io("resources/**/*.blp").unwrap(); 34 | if let Some("1") | Some("true") = std::env::var("SKIP_BLUEPRINT").ok().as_deref() { 35 | println!("cargo::warning=Skipping blueprint compilation, falling back to committed files."); 36 | } else { 37 | Command::new("blueprint-compiler") 38 | .args(["batch-compile", "resources", "resources"]) 39 | .args(&blueprint_files) 40 | .checked(); 41 | } 42 | blueprint_files 43 | } 44 | 45 | /// Run `msgfmt` over a template file to merge translations with the template. 46 | fn msgfmt_template>(template: P) { 47 | let target = template.as_ref().with_extension(""); 48 | let mode = match target.extension().and_then(|e| e.to_str()) { 49 | Some("desktop") => "--desktop", 50 | Some("xml") => "--xml", 51 | other => panic!("Unsupported template extension: {:?}", other), 52 | }; 53 | 54 | Command::new("msgfmt") 55 | .args([mode, "--template"]) 56 | .arg(template.as_ref()) 57 | .args(["-d", "po", "--output"]) 58 | .arg(target) 59 | .checked(); 60 | } 61 | 62 | fn msgfmt() -> Vec { 63 | let po_files: Vec = glob::glob("po/*.po") 64 | .unwrap() 65 | .collect::>() 66 | .unwrap(); 67 | 68 | let msgfmt_exists = Command::new("msgfmt") 69 | .arg("--version") 70 | .status() 71 | .is_ok_and(|status| status.success()); 72 | 73 | let templates = &[ 74 | Path::new("resources/de.swsnr.turnon.metainfo.xml.in").to_owned(), 75 | Path::new("de.swsnr.turnon.desktop.in").to_owned(), 76 | ]; 77 | if msgfmt_exists { 78 | for file in templates { 79 | msgfmt_template(file); 80 | } 81 | } else { 82 | println!("cargo::warning=msgfmt not found; using untranslated desktop and metainfo file."); 83 | for file in templates { 84 | std::fs::copy(file, file.with_extension("")).unwrap(); 85 | } 86 | } 87 | 88 | let mut sources = po_files; 89 | sources.push("po/LINGUAS".into()); 90 | sources.extend_from_slice(templates); 91 | sources 92 | } 93 | 94 | pub fn compile_resources>( 95 | source_dirs: &[P], 96 | gresource: &str, 97 | target: &str, 98 | ) -> Vec { 99 | let out_dir = std::env::var("OUT_DIR").unwrap(); 100 | let out_dir = Path::new(&out_dir); 101 | 102 | let mut command = Command::new("glib-compile-resources"); 103 | 104 | for source_dir in source_dirs { 105 | command.arg("--sourcedir").arg(source_dir.as_ref()); 106 | } 107 | 108 | command 109 | .arg("--target") 110 | .arg(out_dir.join(target)) 111 | .arg(gresource) 112 | .checked(); 113 | 114 | let mut command = Command::new("glib-compile-resources"); 115 | for source_dir in source_dirs { 116 | command.arg("--sourcedir").arg(source_dir.as_ref()); 117 | } 118 | 119 | let output = command 120 | .arg("--generate-dependencies") 121 | .arg(gresource) 122 | .stderr(Stdio::inherit()) 123 | .output() 124 | .unwrap() 125 | .stdout; 126 | 127 | let mut sources = vec![Path::new(gresource).into()]; 128 | 129 | for line in String::from_utf8(output).unwrap().lines() { 130 | if line.ends_with(".ui") { 131 | // We build UI files from blueprint, so adapt the dependency 132 | sources.push(Path::new(line).with_extension("blp")) 133 | } else if line.ends_with(".metainfo.xml") { 134 | sources.push(Path::new(line).with_extension("xml.in")); 135 | } else { 136 | sources.push(line.into()); 137 | } 138 | } 139 | 140 | sources 141 | } 142 | 143 | fn glib_compile_schemas() -> Vec { 144 | let schemas = glob_io("schemas/*.gschema.xml").unwrap(); 145 | Command::new("glib-compile-schemas") 146 | .args(["--strict", "schemas"]) 147 | .checked(); 148 | schemas 149 | } 150 | 151 | fn main() { 152 | let tasks = [ 153 | std::thread::spawn(compile_blueprint), 154 | std::thread::spawn(glib_compile_schemas), 155 | std::thread::spawn(msgfmt), 156 | ]; 157 | 158 | let mut sources = tasks 159 | .into_iter() 160 | .flat_map(|task| task.join().unwrap()) 161 | .collect::>(); 162 | 163 | sources.extend_from_slice( 164 | compile_resources( 165 | &["resources"], 166 | "resources/resources.gresource.xml", 167 | "turnon.gresource", 168 | ) 169 | .as_slice(), 170 | ); 171 | 172 | for source in sources { 173 | println!("cargo:rerun-if-changed={}", source.display()); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /dbus-1/de.swsnr.turnon.service: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=de.swsnr.turnon 3 | Exec=de.swsnr.turnon --gapplication-service 4 | -------------------------------------------------------------------------------- /dbus-1/org.gnome.ShellSearchProvider2.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 13 | 14 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | 56 | 57 | 58 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /de.swsnr.turnon.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | # Translators: Do feel free to translate the application name, as in "Turning on a device". 3 | Name=Turn On 4 | Comment=Turn on devices in your network 5 | Exec=de.swsnr.turnon 6 | Type=Application 7 | Icon=de.swsnr.turnon 8 | Categories=GNOME;GTK;Utility; 9 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 10 | Keywords=GNOME;GTK;WakeOnLan; 11 | StartupNotify=true 12 | DBusActivatable=true 13 | Actions=add-device; 14 | X-GNOME-UsesNotifications=true 15 | 16 | [Desktop Action add-device] 17 | Name=Add new device 18 | Exec=de.swsnr.turnon --add-device 19 | -------------------------------------------------------------------------------- /de.swsnr.turnon.search-provider.ini: -------------------------------------------------------------------------------- 1 | [Shell Search Provider] 2 | DesktopId=de.swsnr.turnon.desktop 3 | BusName=de.swsnr.turnon 4 | ObjectPath=/de/swsnr/turnon/search 5 | Version=2 6 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [graph] 2 | targets = [ 3 | # Flathub builds these targets 4 | { triple = 'x86_64-unknown-linux-gnu' }, 5 | { triple = 'aarch64-unknown-linux-gnu' }, 6 | ] 7 | 8 | [advisories] 9 | version = 2 10 | ignore = [] 11 | 12 | [licenses] 13 | version = 2 14 | allow = [ 15 | "Apache-2.0", 16 | "Apache-2.0 WITH LLVM-exception", 17 | "MIT", 18 | "MPL-2.0", 19 | "Unicode-3.0", 20 | ] 21 | -------------------------------------------------------------------------------- /flatpak/de.swsnr.turnon.Devel.yaml: -------------------------------------------------------------------------------- 1 | id: de.swsnr.turnon.Devel 2 | runtime: org.gnome.Platform 3 | runtime-version: "48" 4 | sdk: org.gnome.Sdk 5 | sdk-extensions: 6 | - org.freedesktop.Sdk.Extension.rust-stable 7 | command: de.swsnr.turnon.Devel 8 | finish-args: 9 | - --share=ipc 10 | - --share=network 11 | - --socket=fallback-x11 12 | - --socket=wayland 13 | - --device=dri 14 | build-options: 15 | append-path: /usr/lib/sdk/rust-stable/bin 16 | env: 17 | # Tell build.rs not to regenerate the UI files from blueprint sources 18 | SKIP_BLUEPRINT: "1" 19 | modules: 20 | - name: turnon 21 | buildsystem: simple 22 | sources: 23 | - type: dir 24 | path: ".." 25 | build-options: 26 | build-args: 27 | - --share=network 28 | build-commands: 29 | # Patch version number and app ID before building our binary 30 | - make APPID=de.swsnr.turnon.Devel patch-git-version patch-appid 31 | - cargo build --locked --release --verbose 32 | - make DESTPREFIX=/app APPID=de.swsnr.turnon.Devel install 33 | - glib-compile-schemas --strict /app/share/glib-2.0/schemas 34 | -------------------------------------------------------------------------------- /flatpak/de.swsnr.turnon.yaml: -------------------------------------------------------------------------------- 1 | id: de.swsnr.turnon 2 | runtime: org.gnome.Platform 3 | runtime-version: "48" 4 | sdk: org.gnome.Sdk 5 | sdk-extensions: 6 | - org.freedesktop.Sdk.Extension.rust-stable 7 | command: de.swsnr.turnon 8 | finish-args: 9 | - --share=ipc 10 | - --share=network 11 | - --socket=fallback-x11 12 | - --socket=wayland 13 | - --device=dri 14 | build-options: 15 | append-path: /usr/lib/sdk/rust-stable/bin 16 | env: 17 | # Tell build.rs not to regenerate the UI files from blueprint sources 18 | SKIP_BLUEPRINT: "1" 19 | modules: 20 | - name: turnon 21 | buildsystem: simple 22 | sources: 23 | - type: archive 24 | url: https://github.com/swsnr/turnon/releases/download/v2.6.3/turnon-v2.6.3.tar.zst 25 | sha512: "61ae7033713766274f54ae2d476c49ac3930621b7fb038846943028d9b0e040d1bd64e20e0ace34e34776842593d6eef7cf4091370574dfc4127845d016f6b7b" 26 | - type: archive 27 | url: https://github.com/swsnr/turnon/releases/download/v2.6.3/turnon-v2.6.3-vendor.tar.zst 28 | sha512: "c9fb8a000ec3b486cadf3f77c9a171c593072b2c9f457ff6a154061d28e8e9f71308f58469a2a6092085ac7b9ce3b185d08dd2a87e043ef06dba482a19add6ad" 29 | dest: vendor/ 30 | - type: inline 31 | dest: .cargo/ 32 | dest-filename: config.toml 33 | contents: | 34 | [source.crates-io] 35 | replace-with = "vendored-sources" 36 | 37 | [source.vendored-sources] 38 | directory = "vendor" 39 | build-commands: 40 | - cargo build --frozen --release --verbose 41 | - make DESTPREFIX=/app install 42 | - glib-compile-schemas --strict /app/share/glib-2.0/schemas 43 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | de 2 | it 3 | nl 4 | he 5 | et 6 | fi 7 | es 8 | -------------------------------------------------------------------------------- /resources/gtk/help-overlay.blp: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | // This Source Code Form is subject to the terms of the Mozilla Public 3 | // License, v. 2.0. If a copy of the MPL was not distributed with this 4 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | using Gtk 4.0; 6 | 7 | /** Documentation for all relevant shortcuts of our app. 8 | * 9 | * Gtk's main application automatically sets up an action with a shortcut to 10 | * show this window if gtk/help-overlay.ui exists. 11 | * 12 | * See https://docs.gtk.org/gtk4/class.Application.html#automatic-resources 13 | */ 14 | Gtk.ShortcutsWindow help_overlay { 15 | modal: true; 16 | 17 | Gtk.ShortcutsSection { 18 | section-name: "shortcuts"; 19 | 20 | Gtk.ShortcutsGroup { 21 | title: C_("shortcuts group", "General"); 22 | 23 | Gtk.ShortcutsShortcut { 24 | title: C_("shortcut description", "Show shortcuts"); 25 | action-name: "win.show-help-overlay"; 26 | } 27 | 28 | Gtk.ShortcutsShortcut { 29 | title: C_("shortcut description", "Quit"); 30 | action-name: "app.quit"; 31 | } 32 | } 33 | 34 | Gtk.ShortcutsGroup { 35 | title: C_("shortcuts group", "Devices"); 36 | 37 | Gtk.ShortcutsShortcut { 38 | title: C_("shortcut description", "Add a new device"); 39 | accelerator: "N"; 40 | } 41 | 42 | Gtk.ShortcutsShortcut { 43 | title: C_("shortcut description", "Toggle network scanning"); 44 | action-name: "app.scan-network"; 45 | } 46 | } 47 | 48 | Gtk.ShortcutsGroup { 49 | title: C_("shortcuts group", "Discovered device"); 50 | 51 | Gtk.ShortcutsShortcut { 52 | title: C_("shortcut description", "Add as a new device"); 53 | accelerator: "N"; 54 | } 55 | } 56 | 57 | Gtk.ShortcutsGroup { 58 | title: C_("shortcuts group", "Edit device"); 59 | 60 | Gtk.ShortcutsShortcut { 61 | title: C_("shortcut description", "Save device"); 62 | accelerator: "S"; 63 | } 64 | } 65 | 66 | Gtk.ShortcutsGroup { 67 | title: C_("shortcuts group", "Single device"); 68 | 69 | Gtk.ShortcutsShortcut { 70 | title: C_("shortcut description", "Turn on device"); 71 | accelerator: "Return"; 72 | } 73 | 74 | Gtk.ShortcutsShortcut { 75 | title: C_("shortcut description", "Edit device"); 76 | accelerator: "Return"; 77 | } 78 | 79 | Gtk.ShortcutsShortcut { 80 | title: C_("shortcut description", "Ask to delete device"); 81 | accelerator: "Delete"; 82 | } 83 | 84 | Gtk.ShortcutsShortcut { 85 | title: C_("shortcut description", "Immediately delete device without confirmation"); 86 | accelerator: "Delete"; 87 | } 88 | 89 | Gtk.ShortcutsShortcut { 90 | title: C_("shortcut description", "Move device upwards"); 91 | accelerator: "Up"; 92 | } 93 | 94 | Gtk.ShortcutsShortcut { 95 | title: C_("shortcut description", "Move device downwards"); 96 | accelerator: "Down"; 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /resources/gtk/help-overlay.ui: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | true 11 | 12 | 13 | shortcuts 14 | 15 | 16 | General 17 | 18 | 19 | Show shortcuts 20 | win.show-help-overlay 21 | 22 | 23 | 24 | 25 | Quit 26 | app.quit 27 | 28 | 29 | 30 | 31 | 32 | 33 | Devices 34 | 35 | 36 | Add a new device 37 | <Ctrl>N 38 | 39 | 40 | 41 | 42 | Toggle network scanning 43 | app.scan-network 44 | 45 | 46 | 47 | 48 | 49 | 50 | Discovered device 51 | 52 | 53 | Add as a new device 54 | <Ctrl>N 55 | 56 | 57 | 58 | 59 | 60 | 61 | Edit device 62 | 63 | 64 | Save device 65 | <Ctrl>S 66 | 67 | 68 | 69 | 70 | 71 | 72 | Single device 73 | 74 | 75 | Turn on device 76 | Return 77 | 78 | 79 | 80 | 81 | Edit device 82 | <Alt>Return 83 | 84 | 85 | 86 | 87 | Ask to delete device 88 | Delete 89 | 90 | 91 | 92 | 93 | Immediately delete device without confirmation 94 | <Ctrl>Delete 95 | 96 | 97 | 98 | 99 | Move device upwards 100 | <Alt>Up 101 | 102 | 103 | 104 | 105 | Move device downwards 106 | <Alt>Down 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/checkmark-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/computer-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/edit-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/menu-large-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/plus-large-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/sonar-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/user-trash-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/warning-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/waves-and-screen-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/apps/de.swsnr.pictureoftheday.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /resources/icons/scalable/apps/de.swsnr.turnon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /resources/icons/symbolic/apps/de.swsnr.turnon-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ui/edit-device-dialog.ui 6 | ui/device-row.ui 7 | ui/validation-indicator.ui 8 | ui/turnon-application-window.ui 9 | 11 | gtk/help-overlay.ui 12 | 13 | icons/scalable/actions/checkmark-symbolic.svg 14 | icons/scalable/actions/computer-symbolic.svg 15 | icons/scalable/actions/edit-symbolic.svg 16 | icons/scalable/actions/menu-large-symbolic.svg 17 | icons/scalable/actions/plus-large-symbolic.svg 18 | icons/scalable/actions/sonar-symbolic.svg 19 | icons/scalable/actions/user-trash-symbolic.svg 20 | icons/scalable/actions/warning-outline-symbolic.svg 21 | icons/scalable/actions/waves-and-screen-symbolic.svg 22 | 23 | icons/scalable/apps/de.swsnr.turnon.svg 24 | icons/scalable/apps/de.swsnr.turnon.Devel.svg 25 | icons/symbolic/apps/de.swsnr.turnon-symbolic.svg 26 | 27 | icons/scalable/apps/de.swsnr.pictureoftheday.svg 28 | 29 | style.css 30 | de.swsnr.turnon.metainfo.xml 31 | 32 | 33 | -------------------------------------------------------------------------------- /resources/style.css: -------------------------------------------------------------------------------- 1 | /* Dim labels of discovered devices */ 2 | row.discovered .title > .title, 3 | row.discovered .suffixes .title { 4 | opacity: var(--dim-opacity); 5 | } 6 | -------------------------------------------------------------------------------- /resources/ui/device-row.blp: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | // This Source Code Form is subject to the terms of the Mozilla Public 3 | // License, v. 2.0. If a copy of the MPL was not distributed with this 4 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $DeviceRow: Adw.ActionRow { 9 | title: bind (template.device as <$Device>).label; 10 | subtitle: bind $device_mac_address(template.device) as ; 11 | activatable: true; 12 | 13 | [prefix] 14 | Gtk.Stack { 15 | visible-child-name: bind $device_state_name(template.is-device-online) as ; 16 | 17 | Gtk.StackPage { 18 | name: "offline"; 19 | 20 | child: Gtk.Image { 21 | icon-name: "sonar-symbolic"; 22 | 23 | styles [ 24 | "error", 25 | ] 26 | }; 27 | } 28 | 29 | Gtk.StackPage { 30 | name: "online"; 31 | 32 | child: Gtk.Image { 33 | icon-name: "sonar-symbolic"; 34 | 35 | styles [ 36 | "success", 37 | ] 38 | }; 39 | } 40 | } 41 | 42 | [suffix] 43 | Gtk.Box { 44 | orientation: horizontal; 45 | spacing: 3; 46 | 47 | Label { 48 | label: bind $device_host((template.device as <$Device>).host, template.device-url) as ; 49 | use-markup: true; 50 | 51 | styles [ 52 | "title", 53 | ] 54 | } 55 | 56 | Gtk.Stack { 57 | visible-child-name: bind template.suffix-mode; 58 | margin-start: 12; 59 | hhomogeneous: false; 60 | transition-type: slide_left_right; 61 | 62 | Gtk.StackPage { 63 | name: "buttons"; 64 | 65 | child: Gtk.Box { 66 | orientation: horizontal; 67 | 68 | Gtk.Button add { 69 | icon-name: "plus-large-symbolic"; 70 | tooltip-text: C_("device-row.action.row.add.tooltip", "Add this device"); 71 | action-name: "row.add"; 72 | valign: center; 73 | visible: bind add.sensitive; 74 | 75 | styles [ 76 | "flat", 77 | ] 78 | } 79 | 80 | Gtk.Button edit { 81 | icon-name: "edit-symbolic"; 82 | tooltip-text: C_("device-row.action.row.edit.tooltip", "Edit this device"); 83 | action-name: "row.edit"; 84 | valign: center; 85 | visible: bind edit.sensitive; 86 | 87 | styles [ 88 | "flat", 89 | ] 90 | } 91 | 92 | Gtk.Button delete { 93 | icon-name: "user-trash-symbolic"; 94 | tooltip-text: C_("device-row.action.row.ask-delete.tooltip", "Delete this device?"); 95 | action-name: "row.ask-delete"; 96 | margin-start: 6; 97 | valign: center; 98 | visible: bind delete.sensitive; 99 | 100 | styles [ 101 | "flat", 102 | ] 103 | } 104 | }; 105 | } 106 | 107 | Gtk.StackPage { 108 | name: "confirm-delete"; 109 | 110 | child: Gtk.Box { 111 | Gtk.Button { 112 | label: C_("device-row.action.row.delete", "Delete"); 113 | valign: center; 114 | action-name: "row.delete"; 115 | 116 | styles [ 117 | "destructive-action", 118 | ] 119 | } 120 | 121 | Gtk.Button { 122 | label: C_("canceldevice-row.action.row.cancel-delete", "Cancel"); 123 | valign: center; 124 | margin-start: 6; 125 | action-name: "row.cancel-delete"; 126 | 127 | styles [ 128 | "flat", 129 | ] 130 | } 131 | }; 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /resources/ui/device-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 174 | -------------------------------------------------------------------------------- /resources/ui/edit-device-dialog.blp: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | // This Source Code Form is subject to the terms of the Mozilla Public 3 | // License, v. 2.0. If a copy of the MPL was not distributed with this 4 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $EditDeviceDialog: Adw.Dialog { 9 | can-close: true; 10 | title: _("Edit device"); 11 | content-width: 450; 12 | default-widget: save; 13 | 14 | Adw.ToolbarView { 15 | [top] 16 | Adw.HeaderBar {} 17 | 18 | Adw.PreferencesPage { 19 | Adw.PreferencesGroup { 20 | Adw.EntryRow { 21 | title: C_("edit-device-dialog.entry.label.title", "Device _label"); 22 | tooltip-text: C_("edit-device-dialog.entry.label.tooltip", "A label to recognize the device by"); 23 | use-underline: true; 24 | text: bind template.label bidirectional; 25 | entry-activated => $move_to_next_entry(); 26 | 27 | [suffix] 28 | $ValidationIndicator { 29 | is_valid: bind template.label_valid; 30 | feedback: C_("edit-device-dialog.entry.label.feedback", "Please provide a label for the device."); 31 | } 32 | } 33 | 34 | Adw.EntryRow mac_entry { 35 | title: C_("edit-device-dialog.entry.mac_address.title", "_MAC address"); 36 | tooltip-text: C_("edit-device-dialog.entry.mac_address.tooltip", "The hardware address for this device"); 37 | input-hints: no_emoji | no_spellcheck | uppercase_chars | private; 38 | use-underline: true; 39 | text: bind template.mac_address bidirectional; 40 | entry-activated => $move_to_next_entry(); 41 | 42 | [suffix] 43 | $ValidationIndicator { 44 | is_valid: bind template.mac_address_valid; 45 | feedback: C_("edit-device-dialog.entry.mac_address.feedback", "This is no valid 48-bit MAC address."); 46 | } 47 | } 48 | 49 | Adw.EntryRow { 50 | title: C_("edit-device-dialog.entry.host.title", "_Host name or IP address"); 51 | tooltip-text: C_("edit-device-dialog.entry.host.tooltip", "The hostname or IP address of the device to check whether it has woken up"); 52 | input-hints: no_emoji | no_spellcheck; 53 | use-underline: true; 54 | text: bind template.host bidirectional; 55 | activates-default: true; 56 | 57 | [suffix] 58 | Gtk.Stack { 59 | visible-child-name: bind template.host_indicator; 60 | 61 | Gtk.StackPage { 62 | name: "invalid-empty"; 63 | 64 | child: Gtk.Image { 65 | icon-name: "warning-outline-symbolic"; 66 | tooltip-text: C_("edit-device-dialog.entry.host.feedback", "Please specify a target host to check availability"); 67 | 68 | styles [ 69 | "error", 70 | ] 71 | }; 72 | } 73 | 74 | Gtk.StackPage { 75 | name: "invalid-socket-address"; 76 | 77 | child: Gtk.Image { 78 | icon-name: "warning-outline-symbolic"; 79 | tooltip-text: C_("edit-device-dialog.entry.socket-address.feedback", "This looks like a socket address with host and port, but a port is not permitted here!"); 80 | 81 | styles [ 82 | "error", 83 | ] 84 | }; 85 | } 86 | 87 | Gtk.StackPage { 88 | name: "host"; 89 | 90 | child: Gtk.Image { 91 | icon-name: "computer-symbolic"; 92 | tooltip-text: C_("edit-device-dialog.entry.host.feedback", "This looks like a generic name resolved via DNS."); 93 | 94 | styles [ 95 | "success", 96 | ] 97 | }; 98 | } 99 | 100 | Gtk.StackPage { 101 | name: "ipv4"; 102 | 103 | child: Gtk.Label { 104 | label: "v4"; 105 | use-markup: true; 106 | tooltip-text: C_("edit-device-dialog.entry.host.feedback", "This is a valid IPv4 address."); 107 | 108 | styles [ 109 | "success", 110 | ] 111 | }; 112 | } 113 | 114 | Gtk.StackPage { 115 | name: "ipv6"; 116 | 117 | child: Gtk.Label { 118 | label: "v6"; 119 | use-markup: true; 120 | tooltip-text: C_("edit-device-dialog.entry.host.feedback", "This is a valid IPv6 address."); 121 | 122 | styles [ 123 | "success", 124 | ] 125 | }; 126 | } 127 | } 128 | } 129 | 130 | Gtk.Button save { 131 | label: _("_Save"); 132 | use-underline: true; 133 | action-name: "device.save"; 134 | halign: center; 135 | margin-top: 12; 136 | 137 | styles [ 138 | "pill", 139 | "suggested-action", 140 | ] 141 | } 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /resources/ui/edit-device-dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 161 | -------------------------------------------------------------------------------- /resources/ui/turnon-application-window.blp: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | // This Source Code Form is subject to the terms of the Mozilla Public 3 | // License, v. 2.0. If a copy of the MPL was not distributed with this 4 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $TurnOnApplicationWindow: Adw.ApplicationWindow { 9 | title: _("Turn On"); 10 | 11 | content: Adw.ToolbarView { 12 | top-bar-style: raised; 13 | 14 | [top] 15 | Adw.HeaderBar { 16 | [start] 17 | Gtk.Button { 18 | action-name: "app.add-device"; 19 | icon-name: "plus-large-symbolic"; 20 | tooltip-text: C_("application-window.action.app.add-device.tooltip", "Add a new device"); 21 | } 22 | 23 | [start] 24 | Gtk.ToggleButton toggle_scan_network { 25 | action-name: "app.scan-network"; 26 | icon-name: "waves-and-screen-symbolic"; 27 | tooltip-text: C_("application-window.action.app.scan-network.tooltip", "Scan the network for devices"); 28 | } 29 | 30 | [end] 31 | MenuButton button_menu { 32 | menu-model: main_menu; 33 | icon-name: 'menu-large-symbolic'; 34 | primary: true; 35 | } 36 | } 37 | 38 | Adw.ToastOverlay feedback { 39 | Gtk.ScrolledWindow { 40 | Gtk.ListBox devices_list { 41 | selection-mode: none; 42 | vexpand: true; 43 | hexpand: true; 44 | margin-start: 12; 45 | margin-end: 12; 46 | margin-top: 12; 47 | margin-bottom: 12; 48 | 49 | styles [ 50 | "boxed-list-separate", 51 | ] 52 | 53 | [placeholder] 54 | Adw.StatusPage { 55 | title: C_("application-window.status-page.title", "No devices"); 56 | description: C_("application-window.status-page.description", "Add a new device to turn it on."); 57 | icon-name: bind template.startpage-icon-name; 58 | vexpand: true; 59 | 60 | styles [ 61 | "compact", 62 | ] 63 | 64 | child: Adw.Clamp { 65 | Gtk.Box { 66 | orientation: vertical; 67 | homogeneous: true; 68 | halign: center; 69 | 70 | Gtk.Button { 71 | label: C_("application-window.status-page.button.label", "Add new device"); 72 | action-name: "app.add-device"; 73 | 74 | styles [ 75 | "pill", 76 | "suggested-action", 77 | ] 78 | } 79 | 80 | Gtk.Button { 81 | label: C_("application-window.status-page.button.label", "Scan network"); 82 | action-name: "app.scan-network"; 83 | 84 | styles [ 85 | "pill", 86 | "suggested-action", 87 | ] 88 | } 89 | } 90 | }; 91 | } 92 | } 93 | } 94 | } 95 | }; 96 | } 97 | 98 | menu main_menu { 99 | section { 100 | item { 101 | label: C_("application-window.menu.label", "_Keyboard Shortcuts"); 102 | action: "win.show-help-overlay"; 103 | } 104 | 105 | item { 106 | label: C_("application-window.menu.label", "_About Turn On"); 107 | action: "app.about"; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /resources/ui/turnon-application-window.ui: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 106 | 107 |
108 | 109 | _Keyboard Shortcuts 110 | win.show-help-overlay 111 | 112 | 113 | _About Turn On 114 | app.about 115 | 116 |
117 |
118 |
-------------------------------------------------------------------------------- /resources/ui/validation-indicator.blp: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | // This Source Code Form is subject to the terms of the Mozilla Public 3 | // License, v. 2.0. If a copy of the MPL was not distributed with this 4 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $ValidationIndicator: Adw.Bin { 9 | Gtk.Stack indicator { 10 | visible-child: invalid; 11 | 12 | Gtk.Image invalid { 13 | icon-name: "warning-outline-symbolic"; 14 | tooltip-text: bind template.feedback; 15 | 16 | styles [ 17 | "error", 18 | ] 19 | } 20 | 21 | Gtk.Image valid { 22 | icon-name: "checkmark-symbolic"; 23 | 24 | styles [ 25 | "success", 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /resources/ui/validation-indicator.ui: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 33 | -------------------------------------------------------------------------------- /schemas/de.swsnr.turnon.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 480 5 | 6 | 7 | 480 8 | 9 | 10 | false 11 | 12 | 13 | false 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /screenshots/arp: -------------------------------------------------------------------------------- 1 | IP address HW type Flags HW address Mask Device 2 | 192.168.2.100 0x1 0x2 62:6f:a3:f8:3e:ef * eth0 3 | 192.168.2.101 0x1 0x2 a3:6f:7f:3a:9d:ff * eth0 4 | -------------------------------------------------------------------------------- /screenshots/devices.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "NAS", 4 | "mac_address": "2E:E3:50:A3:E2:F7", 5 | "host": "192.168.2.100" 6 | }, 7 | { 8 | "label": "Desktop", 9 | "mac_address": "BF:B4:3B:24:C9:47", 10 | "host": "my-desktop.local" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /screenshots/edit-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swsnr/turnon/9b595ad3636ce3fbeca45f6becddea20a43f14ed/screenshots/edit-device.png -------------------------------------------------------------------------------- /screenshots/list-of-devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swsnr/turnon/9b595ad3636ce3fbeca45f6becddea20a43f14ed/screenshots/list-of-devices.png -------------------------------------------------------------------------------- /screenshots/list-of-discovered-devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swsnr/turnon/9b595ad3636ce3fbeca45f6becddea20a43f14ed/screenshots/list-of-discovered-devices.png -------------------------------------------------------------------------------- /screenshots/run-for-screenshot.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | DEVICES_FILE="${1:-"$(git rev-parse --show-toplevel)/screenshots/devices.json"}" 6 | 7 | variables=( 8 | # Run app with default settings: Force the in-memory gsettings backend to 9 | # block access to standard Gtk settings, and tell Adwaita not to access 10 | # portals to prevent it from getting dark mode and accent color from desktop 11 | # settings. 12 | # 13 | # Effectively this makes our app run with default settings. 14 | GSETTINGS_BACKEND=memory 15 | ADW_DISABLE_PORTAL=1 16 | ) 17 | 18 | exec env "${variables[@]}" cargo run -- \ 19 | --devices-file "${DEVICES_FILE}" \ 20 | --arp-cache-file "$(git rev-parse --show-toplevel)/screenshots/arp" 21 | -------------------------------------------------------------------------------- /screenshots/start-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swsnr/turnon/9b595ad3636ce3fbeca45f6becddea20a43f14ed/screenshots/start-page.png -------------------------------------------------------------------------------- /scripts/build-social-image.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | set -euo pipefail 3 | 4 | toplevel="$(git rev-parse --show-toplevel)" 5 | screenshots="${toplevel}/screenshots" 6 | social_image="${toplevel}/social-image.png" 7 | 8 | montage -geometry 602x602+19+19 \ 9 | "${screenshots}"/start-page.png "${screenshots}"/list-of-discovered-devices.png \ 10 | "${social_image}" 11 | oxipng "${social_image}" 12 | -------------------------------------------------------------------------------- /scripts/prerelease.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Copyright Sebastian Wiesner 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Prerelease script for cargo release. 10 | """ 11 | 12 | import os 13 | import sys 14 | from pathlib import Path 15 | from typing import NamedTuple, Self 16 | import xml.etree.ElementTree as etree 17 | 18 | class Version(NamedTuple): 19 | major: int 20 | minor: int 21 | patch: int 22 | 23 | @classmethod 24 | def parse(cls, s: str) -> Self: 25 | [major, minor, patch] = [int(p) for p in s.split('.')] 26 | return cls(major, minor, patch) 27 | 28 | def __str__(self) -> str: 29 | return f'{self.major}.{self.minor}.{self.patch}' 30 | 31 | 32 | def is_patch_release(prev_version: Version, new_version: Version) -> bool: 33 | return prev_version.major == new_version.major and \ 34 | prev_version.minor == new_version.minor and \ 35 | prev_version.patch != new_version.patch 36 | 37 | 38 | def assert_no_releasenotes(new_version: Version): 39 | metadata_file = Path(os.environ['CRATE_ROOT']) / 'resources' / 'de.swsnr.turnon.metainfo.xml.in' 40 | tree = etree.parse(metadata_file) 41 | if tree.find('./releases/release[@version="next"]') is not None: 42 | raise ValueError('Upcoming release notes found; must do a major or minor release, not a patch release!') 43 | if tree.find(f'./releases/release[@version="{new_version}"]') is not None: 44 | raise ValueError('Release notes for next version found; must do a major or minor release, not a patch release!') 45 | 46 | 47 | def update_releasenotes(new_version: Version, *, tag_name: str, date: str, dry_run: bool): 48 | metadata_file = Path(os.environ['CRATE_ROOT']) / 'resources' / 'de.swsnr.turnon.metainfo.xml.in' 49 | parser = etree.XMLParser(target=etree.TreeBuilder(insert_comments=True)) 50 | tree = etree.parse(metadata_file, parser) 51 | next_release = tree.find('./releases/release[@version="next"]') 52 | if next_release is None: 53 | raise ValueError("Doing a major or minor release but no release notes found!") 54 | next_release.attrib['version'] = str(new_version) 55 | next_release.attrib['date'] = date 56 | url = next_release.find('./url') 57 | if url is None: 58 | # Add new URL tag with appropriate space 59 | next_release[-1].tail = next_release.text 60 | url = etree.SubElement(next_release, 'url') 61 | url.tail = next_release.tail 62 | url.text = f'https://github.com/swsnr/turnon/releases/tag/{tag_name}' 63 | if dry_run: 64 | etree.dump(tree) 65 | else: 66 | tree.write(metadata_file, xml_declaration=True, encoding='utf-8') 67 | 68 | 69 | def main(): 70 | prev_version = Version.parse(os.environ['PREV_VERSION']) 71 | new_version = Version.parse(os.environ['NEW_VERSION']) 72 | dry_run = os.environ['DRY_RUN'] == 'true' 73 | if is_patch_release(prev_version, new_version): 74 | assert_no_releasenotes(new_version) 75 | else: 76 | [tag_name, date] = sys.argv[1:] 77 | update_releasenotes(new_version, tag_name=tag_name, date=date, dry_run=dry_run) 78 | 79 | 80 | if __name__ == '__main__': 81 | main() 82 | -------------------------------------------------------------------------------- /social-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swsnr/turnon/9b595ad3636ce3fbeca45f6becddea20a43f14ed/social-image.png -------------------------------------------------------------------------------- /src/app/commandline.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::{borrow::Cow, future::Future, time::Duration}; 8 | 9 | use futures_util::{StreamExt, stream::FuturesOrdered}; 10 | use gio::prelude::*; 11 | use glib::dpgettext2; 12 | use gtk::gio; 13 | 14 | use crate::app::TurnOnApplication; 15 | use crate::config::G_LOG_DOMAIN; 16 | use crate::futures::future_with_timeout; 17 | use crate::net::PingDestination; 18 | 19 | use super::model::Device; 20 | 21 | async fn turn_on_device( 22 | command_line: &gio::ApplicationCommandLine, 23 | device: &Device, 24 | ) -> glib::ExitCode { 25 | match device.wol().await { 26 | Ok(()) => { 27 | command_line.print_literal( 28 | &dpgettext2( 29 | None, 30 | "option.turn-on-device.message", 31 | "Sent magic packet to mac address %1 of device %2\n", 32 | ) 33 | .replace("%1", &device.mac_address().to_string()) 34 | .replace("%2", &device.label()), 35 | ); 36 | glib::ExitCode::SUCCESS 37 | } 38 | Err(error) => { 39 | command_line.printerr_literal( 40 | &dpgettext2( 41 | None, 42 | "option.turn-on-device.error", 43 | "Failed to turn on device %1: %2\n", 44 | ) 45 | .replace("%1", &device.label()) 46 | .replace("%2", &error.to_string()), 47 | ); 48 | glib::ExitCode::FAILURE 49 | } 50 | } 51 | } 52 | 53 | pub fn turn_on_device_by_label( 54 | app: &TurnOnApplication, 55 | command_line: &gio::ApplicationCommandLine, 56 | label: &str, 57 | ) -> glib::ExitCode { 58 | let guard = app.hold(); 59 | glib::debug!("Turning on device in response to command line argument"); 60 | let registered_devices = app.devices().registered_devices(); 61 | let device = registered_devices 62 | .find_with_equal_func(|o| { 63 | o.downcast_ref::() 64 | .filter(|d| d.label() == label) 65 | .is_some() 66 | }) 67 | .and_then(|position| registered_devices.item(position)) 68 | .and_then(|o| o.downcast::().ok()); 69 | if let Some(device) = device { 70 | glib::spawn_future_local(glib::clone!( 71 | #[strong] 72 | command_line, 73 | async move { 74 | let exit_code = turn_on_device(&command_line, &device).await; 75 | command_line.set_exit_status(exit_code.value()); 76 | command_line.done(); 77 | drop(guard); 78 | } 79 | )); 80 | glib::ExitCode::SUCCESS 81 | } else { 82 | command_line.printerr_literal( 83 | &dpgettext2( 84 | None, 85 | "option.turn-on-device.error", 86 | "No device found for label %s\n", 87 | ) 88 | .replace("%s", label), 89 | ); 90 | glib::ExitCode::FAILURE 91 | } 92 | } 93 | 94 | async fn ping_device(device: Device) -> (Device, Result) { 95 | let destination = PingDestination::from(device.host()); 96 | let result = future_with_timeout(Duration::from_millis(500), destination.ping(1)).await; 97 | (device, result.map(|v| v.1)) 98 | } 99 | 100 | pub fn ping_all_devices>( 101 | devices: I, 102 | ) -> impl Future)>> { 103 | devices 104 | .into_iter() 105 | .map(ping_device) 106 | .collect::>() 107 | .collect::>() 108 | } 109 | 110 | pub fn list_devices( 111 | app: &TurnOnApplication, 112 | command_line: &gio::ApplicationCommandLine, 113 | ) -> glib::ExitCode { 114 | let guard = app.hold(); 115 | glib::spawn_future_local(glib::clone!( 116 | #[strong] 117 | app, 118 | #[strong] 119 | command_line, 120 | async move { 121 | let pinged_devices = ping_all_devices( 122 | app.devices() 123 | .registered_devices() 124 | .into_iter() 125 | .map(|o| o.unwrap().downcast().unwrap()), 126 | ) 127 | .await; 128 | let (label_width, host_width) = 129 | pinged_devices.iter().fold((0, 0), |(lw, hw), (device, _)| { 130 | ( 131 | lw.max(device.label().chars().count()), 132 | hw.max(device.host().chars().count()), 133 | ) 134 | }); 135 | for (device, result) in pinged_devices { 136 | let (color, indicator) = match result { 137 | Ok(duration) => ( 138 | "\x1b[1;32m", 139 | Cow::Owned(format!("{:3}ms", duration.as_millis())), 140 | ), 141 | Err(_) => ("\x1b[1;31m", Cow::Borrowed(" ●")), 142 | }; 143 | command_line.print_literal(&format!( 144 | "{}{}\x1b[0m {:label_width$}\t{}\t{:host_width$}\n", 145 | color, 146 | indicator, 147 | device.label(), 148 | device.mac_address(), 149 | device.host() 150 | )); 151 | } 152 | command_line.set_exit_status(glib::ExitCode::SUCCESS.value()); 153 | command_line.done(); 154 | drop(guard); 155 | } 156 | )); 157 | glib::ExitCode::SUCCESS 158 | } 159 | -------------------------------------------------------------------------------- /src/app/debuginfo.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::borrow::Cow; 8 | use std::fmt::Display; 9 | use std::net::IpAddr; 10 | use std::time::Duration; 11 | 12 | use futures_util::FutureExt; 13 | use futures_util::StreamExt; 14 | use futures_util::stream::{FuturesOrdered, FuturesUnordered}; 15 | use gtk::gio; 16 | use gtk::prelude::*; 17 | use macaddr::MacAddr6; 18 | 19 | use crate::config; 20 | use crate::futures::future_with_timeout; 21 | use crate::net::arpcache::{ArpCacheEntry, default_arp_cache_path, read_arp_cache_from_path}; 22 | use crate::net::{PingDestination, ping_address}; 23 | 24 | use super::model::{Device, Devices}; 25 | 26 | #[derive(Debug)] 27 | pub enum DevicePingResult { 28 | ResolveFailed(glib::Error), 29 | Pinged(Vec<(IpAddr, Result)>), 30 | } 31 | 32 | async fn ping_device(device: Device) -> (Device, DevicePingResult) { 33 | // For debug info we use a very aggressive timeout for resolution and pings. 34 | // We expect everything to be in the local network anyways. 35 | let timeout = Duration::from_millis(500); 36 | let destination = PingDestination::from(device.host()); 37 | 38 | match future_with_timeout(timeout, destination.resolve()).await { 39 | Err(error) => (device, DevicePingResult::ResolveFailed(error)), 40 | Ok(addresses) => { 41 | let pings = addresses 42 | .into_iter() 43 | .map(|addr| { 44 | future_with_timeout(timeout, ping_address(addr, 1)).map(move |r| (addr, r)) 45 | }) 46 | .collect::>() 47 | .collect::>() 48 | .await; 49 | 50 | (device, DevicePingResult::Pinged(pings)) 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug)] 56 | pub struct DebugInfo { 57 | /// The application ID we're running under. 58 | /// 59 | /// Differentiate between the nightly devel package, and the released version. 60 | pub app_id: &'static str, 61 | /// The version. 62 | pub version: &'static str, 63 | /// Whether the application runs inside a flatpak sandbox. 64 | pub flatpak: bool, 65 | /// Overall network connectivity 66 | pub connectivity: gio::NetworkConnectivity, 67 | /// Results from pinging devices once, for debugging. 68 | pub ping_results: Vec<(Device, DevicePingResult)>, 69 | /// Raw contents of ARP cache. 70 | pub arp_cache_contents: std::io::Result, 71 | /// Parsed contents of ARP cache. 72 | pub parsed_arp_cache: std::io::Result>, 73 | } 74 | 75 | impl DebugInfo { 76 | /// Assemble debug information for Turn On. 77 | /// 78 | /// This method returns a human-readable plain text debug report which can help 79 | /// to identify issues. 80 | pub async fn assemble(devices: Devices) -> DebugInfo { 81 | let monitor = gio::NetworkMonitor::default(); 82 | let (connectivity, ping_results) = futures_util::future::join( 83 | // Give network monitor time to actually figure out what the state of the network is, 84 | // especially inside a flatpak sandbox, see https://gitlab.gnome.org/GNOME/glib/-/issues/1718 85 | glib::timeout_future(Duration::from_millis(500)).map(|()| monitor.connectivity()), 86 | std::iter::once(Device::new( 87 | "localhost", 88 | MacAddr6::nil().into(), 89 | "localhost", 90 | )) 91 | .chain( 92 | devices 93 | .registered_devices() 94 | .into_iter() 95 | .map(|d| d.unwrap().downcast().unwrap()), 96 | ) 97 | .map(ping_device) 98 | .collect::>() 99 | .collect::>(), 100 | ) 101 | .await; 102 | let arp_cache_contents = 103 | gio::spawn_blocking(|| std::fs::read_to_string(default_arp_cache_path())) 104 | .await 105 | .unwrap(); 106 | let parsed_arp_cache = gio::spawn_blocking(|| { 107 | read_arp_cache_from_path(default_arp_cache_path()).and_then(Iterator::collect) 108 | }) 109 | .await 110 | .unwrap(); 111 | DebugInfo { 112 | app_id: config::APP_ID, 113 | version: config::CARGO_PKG_VERSION, 114 | flatpak: config::running_in_flatpak(), 115 | connectivity, 116 | ping_results, 117 | arp_cache_contents, 118 | parsed_arp_cache, 119 | } 120 | } 121 | 122 | pub fn suggested_file_name(&self) -> String { 123 | format!("{}-{}-debug.txt", self.app_id, self.version) 124 | } 125 | } 126 | 127 | impl Display for DebugInfo { 128 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 129 | let connectivity: Cow<'static, str> = match self.connectivity { 130 | gio::NetworkConnectivity::Local => "Local".into(), 131 | gio::NetworkConnectivity::Limited => "Limited".into(), 132 | gio::NetworkConnectivity::Portal => "Portal".into(), 133 | gio::NetworkConnectivity::Full => "Full".into(), 134 | other => format!("Other {other:?}").into(), 135 | }; 136 | let pings = 137 | self.ping_results 138 | .iter() 139 | .map(|(d, r)| match r { 140 | DevicePingResult::ResolveFailed(error) => { 141 | format!("Host {}\n Failed to resolve: {error}", d.host()) 142 | } 143 | DevicePingResult::Pinged(addresses) => { 144 | format!( 145 | "Host {}:\n{}", 146 | d.host(), 147 | addresses 148 | .iter() 149 | .map(|(addr, result)| { 150 | format!( 151 | " {addr}: {}", 152 | result.as_ref().map_or_else( 153 | ToString::to_string, 154 | |d| format!("{}ms", d.as_millis()) 155 | ) 156 | ) 157 | }) 158 | .collect::>() 159 | .join("\n") 160 | ) 161 | } 162 | }) 163 | .collect::>() 164 | .join("\n"); 165 | let arp_cache_contents = match &self.arp_cache_contents { 166 | Ok(contents) => Cow::Borrowed(contents), 167 | Err(error) => Cow::Owned(format!("Failed: {error}")), 168 | }; 169 | let parsed_arp_cache = match &self.parsed_arp_cache { 170 | Ok(entries) => entries 171 | .iter() 172 | .map(|entry| format!("{entry:?}")) 173 | .collect::>() 174 | .join("\n"), 175 | Err(error) => format!("Failed: {error}"), 176 | }; 177 | writeln!( 178 | f, 179 | "DEBUG REPORT {} {} 180 | 181 | THIS REPORT CONTAINS HOST NAMES, IP ADDRESSES, AND HARDWARE ADDRESSES OF YOUR DEVICES. 182 | 183 | IF YOU CONSIDER THIS REPORT SENSITIVE DO NOT POST IT PUBLICLY! 184 | 185 | Flatpak? {} 186 | Network connectivity: {connectivity} 187 | 188 | {pings} 189 | 190 | ARP cache contents: 191 | {arp_cache_contents} 192 | 193 | Parsed ARP cache: 194 | {parsed_arp_cache}", 195 | self.app_id, self.version, self.flatpak, 196 | )?; 197 | Ok(()) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/app/model.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | mod device; 8 | mod device_discovery; 9 | mod devices; 10 | 11 | pub use device::Device; 12 | pub use device_discovery::DeviceDiscovery; 13 | pub use devices::Devices; 14 | -------------------------------------------------------------------------------- /src/app/model/device.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::time::Duration; 8 | 9 | use gtk::glib; 10 | 11 | use crate::config::G_LOG_DOMAIN; 12 | use crate::futures::future_with_timeout; 13 | use crate::net::{MacAddr6Boxed, wol}; 14 | 15 | glib::wrapper! { 16 | pub struct Device(ObjectSubclass); 17 | } 18 | 19 | impl Device { 20 | pub fn new(label: &str, mac_address: MacAddr6Boxed, host: &str) -> Self { 21 | glib::Object::builder() 22 | .property("label", label) 23 | .property("mac_address", mac_address) 24 | .property("host", host) 25 | .build() 26 | } 27 | 28 | /// Send the magic packet to this device. 29 | pub async fn wol(&self) -> Result<(), glib::Error> { 30 | let mac_address = self.mac_address(); 31 | glib::info!( 32 | "Sending magic packet for mac address {mac_address} of device {}", 33 | self.label() 34 | ); 35 | let wol_timeout = Duration::from_secs(5); 36 | future_with_timeout(wol_timeout, wol(*mac_address)) 37 | .await 38 | .inspect(|()| { 39 | glib::info!( 40 | "Sent magic packet to {mac_address} of device {}", 41 | self.label() 42 | ); 43 | }) 44 | .inspect_err(|error| { 45 | glib::warn!( 46 | "Failed to send magic packet to {mac_address} of device{}: {error}", 47 | self.label() 48 | ); 49 | }) 50 | } 51 | } 52 | 53 | impl Default for Device { 54 | fn default() -> Self { 55 | glib::Object::builder().build() 56 | } 57 | } 58 | 59 | mod imp { 60 | use std::cell::RefCell; 61 | 62 | use glib::prelude::*; 63 | use glib::subclass::prelude::*; 64 | use gtk::glib; 65 | 66 | use crate::net::MacAddr6Boxed; 67 | 68 | #[derive(Debug, Default, glib::Properties)] 69 | #[properties(wrapper_type = super::Device)] 70 | pub struct Device { 71 | /// The human-readable label for this device, for display in the UI. 72 | #[property(get, set)] 73 | pub label: RefCell, 74 | /// The MAC address of the device to wake. 75 | #[property(get, set)] 76 | pub mac_address: RefCell, 77 | /// The host name or IP 4/6 address of the device, to check whether it is reachable. 78 | #[property(get, set)] 79 | pub host: RefCell, 80 | } 81 | 82 | #[glib::object_subclass] 83 | impl ObjectSubclass for Device { 84 | const NAME: &'static str = "Device"; 85 | 86 | type Type = super::Device; 87 | } 88 | 89 | #[glib::derived_properties] 90 | impl ObjectImpl for Device {} 91 | } 92 | -------------------------------------------------------------------------------- /src/app/model/device_discovery.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::path::Path; 8 | 9 | use glib::{Object, dpgettext2}; 10 | use gtk::gio; 11 | use gtk::gio::prelude::*; 12 | 13 | use crate::config::G_LOG_DOMAIN; 14 | use crate::net::arpcache::{ 15 | ArpCacheEntryFlags, ArpHardwareType, ArpKnownHardwareType, read_arp_cache_from_path, 16 | }; 17 | 18 | use super::Device; 19 | 20 | glib::wrapper! { 21 | /// Device discovery. 22 | pub struct DeviceDiscovery(ObjectSubclass) @implements gio::ListModel; 23 | } 24 | 25 | impl Default for DeviceDiscovery { 26 | fn default() -> Self { 27 | Object::builder().build() 28 | } 29 | } 30 | 31 | mod imp { 32 | use gtk::gio; 33 | use gtk::gio::prelude::*; 34 | use gtk::gio::subclass::prelude::*; 35 | 36 | use std::cell::{Cell, RefCell}; 37 | use std::path::PathBuf; 38 | 39 | use crate::config::G_LOG_DOMAIN; 40 | use crate::net::arpcache::default_arp_cache_path; 41 | 42 | use super::resolve_device_host_to_label; 43 | use super::{super::Device, devices_from_arp_cache}; 44 | 45 | #[derive(Debug, glib::Properties)] 46 | #[properties(wrapper_type = super::DeviceDiscovery)] 47 | pub struct DeviceDiscovery { 48 | #[property(get, set = Self::set_discovery_enabled, explicit_notify)] 49 | discovery_enabled: Cell, 50 | #[property(get, set)] 51 | arp_cache_file: RefCell, 52 | discovered_devices: RefCell>, 53 | } 54 | 55 | impl DeviceDiscovery { 56 | fn set_discovery_enabled(&self, enabled: bool) { 57 | if self.discovery_enabled.get() == enabled { 58 | // Do nothing if the discovery state is already up to date. 59 | return; 60 | } 61 | self.discovery_enabled.replace(enabled); 62 | self.obj().notify_discovery_enabled(); 63 | if enabled { 64 | self.scan_devices(); 65 | } else { 66 | let mut discovered_devices = self.discovered_devices.borrow_mut(); 67 | let n_items_removed = discovered_devices.len(); 68 | discovered_devices.clear(); 69 | // Drop mutable borrow of devices before emtting the signal, because signal handlers 70 | // can already try to access the mdoel 71 | drop(discovered_devices); 72 | self.obj() 73 | .items_changed(0, n_items_removed.try_into().unwrap(), 0); 74 | } 75 | } 76 | 77 | /// Scan the local ARP cache for devices. 78 | fn scan_devices(&self) { 79 | let discovery = self.obj().clone(); 80 | glib::spawn_future_local(async move { 81 | match devices_from_arp_cache(discovery.arp_cache_file()).await { 82 | Ok(devices_from_arp_cache) => { 83 | if discovery.discovery_enabled() { 84 | // If discovery is still enabled remember all discovered devices 85 | let mut devices = discovery.imp().discovered_devices.borrow_mut(); 86 | let len_before = devices.len(); 87 | devices.extend(devices_from_arp_cache); 88 | let n_changed = devices.len() - len_before; 89 | drop(devices); 90 | discovery.items_changed( 91 | len_before.try_into().unwrap(), 92 | 0, 93 | n_changed.try_into().unwrap(), 94 | ); 95 | discovery.imp().reverse_lookup_devices(); 96 | } 97 | } 98 | Err(error) => { 99 | glib::warn!("Failed to read ARP cache: {error}"); 100 | } 101 | } 102 | }); 103 | } 104 | 105 | /// Reverse-lookup the DNS names of all currently discovered devices. 106 | fn reverse_lookup_devices(&self) { 107 | for device in self.discovered_devices.borrow().iter() { 108 | glib::spawn_future_local(glib::clone!( 109 | #[weak] 110 | device, 111 | async move { 112 | resolve_device_host_to_label(device).await; 113 | } 114 | )); 115 | } 116 | } 117 | } 118 | 119 | #[glib::object_subclass] 120 | impl ObjectSubclass for DeviceDiscovery { 121 | const NAME: &'static str = "DeviceDiscovery"; 122 | 123 | type Type = super::DeviceDiscovery; 124 | 125 | type Interfaces = (gio::ListModel,); 126 | 127 | fn new() -> Self { 128 | Self { 129 | discovery_enabled: Cell::default(), 130 | arp_cache_file: RefCell::new(default_arp_cache_path().into()), 131 | discovered_devices: RefCell::default(), 132 | } 133 | } 134 | } 135 | 136 | #[glib::derived_properties] 137 | impl ObjectImpl for DeviceDiscovery {} 138 | 139 | impl ListModelImpl for DeviceDiscovery { 140 | fn item_type(&self) -> glib::Type { 141 | Device::static_type() 142 | } 143 | 144 | fn n_items(&self) -> u32 { 145 | self.discovered_devices.borrow().len().try_into().unwrap() 146 | } 147 | 148 | fn item(&self, position: u32) -> Option { 149 | self.discovered_devices 150 | .borrow() 151 | .get(usize::try_from(position).ok()?) 152 | .map(|d| d.clone().upcast()) 153 | } 154 | } 155 | } 156 | 157 | /// Resolve the host of `device` to a DNS name and use it as label. 158 | async fn resolve_device_host_to_label(device: Device) { 159 | if let Some(address) = gio::InetAddress::from_string(&device.host()) { 160 | match gio::Resolver::default() 161 | .lookup_by_address_future(&address) 162 | .await 163 | { 164 | Ok(name) => { 165 | device.set_label(name); 166 | } 167 | Err(error) => { 168 | glib::warn!("Failed to resolve address {address} into DNS name: {error}"); 169 | } 170 | } 171 | } 172 | } 173 | 174 | /// Read devices from the ARP cache. 175 | /// 176 | /// Return an error if opening the ARP cache file failed; otherwise return a 177 | /// (potentially empty) iterator of all devices found in the ARP cache, skipping 178 | /// over invalid or malformed entries. 179 | /// 180 | /// All discovered devices have their IP address has `host` and a constant 181 | /// human readable and translated `label`. 182 | async fn devices_from_arp_cache + Send + 'static>( 183 | arp_cache_file: P, 184 | ) -> std::io::Result> { 185 | let arp_cache = gio::spawn_blocking(move || read_arp_cache_from_path(arp_cache_file)) 186 | .await 187 | .unwrap()?; 188 | 189 | Ok(arp_cache 190 | .filter_map(|item| { 191 | item.inspect_err(|error| { 192 | glib::warn!("Failed to parse ARP cache entry: {error}"); 193 | }) 194 | .ok() 195 | }) 196 | // Only consider ethernet devices 197 | .filter(|entry| entry.hardware_type == ArpHardwareType::Known(ArpKnownHardwareType::Ether)) 198 | // Only include complete ARP cache entries, where the hardware address is fully known and valid 199 | .filter(|entry| entry.flags.contains(ArpCacheEntryFlags::ATF_COM)) 200 | .map(|entry| { 201 | Device::new( 202 | &dpgettext2(None, "discovered-device.label", "Discovered device"), 203 | entry.hardware_address.into(), 204 | &entry.ip_address.to_string(), 205 | ) 206 | })) 207 | } 208 | -------------------------------------------------------------------------------- /src/app/model/devices.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use gtk::gio; 8 | 9 | glib::wrapper! { 10 | pub struct Devices(ObjectSubclass) @implements gio::ListModel; 11 | } 12 | 13 | impl Default for Devices { 14 | fn default() -> Self { 15 | glib::Object::builder().build() 16 | } 17 | } 18 | 19 | mod imp { 20 | use glib::types::StaticType; 21 | use gtk::gio; 22 | use gtk::gio::prelude::*; 23 | use gtk::gio::subclass::prelude::*; 24 | 25 | use crate::app::model::DeviceDiscovery; 26 | 27 | use super::super::Device; 28 | 29 | #[derive(Debug, glib::Properties)] 30 | #[properties(wrapper_type = super::Devices)] 31 | pub struct Devices { 32 | #[property(get)] 33 | pub registered_devices: gio::ListStore, 34 | #[property(get)] 35 | pub discovered_devices: DeviceDiscovery, 36 | } 37 | 38 | #[glib::object_subclass] 39 | impl ObjectSubclass for Devices { 40 | const NAME: &'static str = "Devices"; 41 | 42 | type Type = super::Devices; 43 | 44 | type Interfaces = (gio::ListModel,); 45 | 46 | fn new() -> Self { 47 | Self { 48 | registered_devices: gio::ListStore::with_type(Device::static_type()), 49 | discovered_devices: DeviceDiscovery::default(), 50 | } 51 | } 52 | } 53 | 54 | #[glib::derived_properties] 55 | impl ObjectImpl for Devices { 56 | fn constructed(&self) { 57 | self.parent_constructed(); 58 | 59 | self.registered_devices.connect_items_changed(glib::clone!( 60 | #[strong(rename_to=devices)] 61 | self.obj(), 62 | move |_, position, removed, added| { 63 | devices.items_changed(position, removed, added); 64 | } 65 | )); 66 | self.discovered_devices.connect_items_changed(glib::clone!( 67 | #[strong(rename_to=devices)] 68 | self.obj(), 69 | move |_, position, removed, added| { 70 | devices.items_changed( 71 | position + devices.registered_devices().n_items(), 72 | removed, 73 | added, 74 | ); 75 | } 76 | )); 77 | } 78 | } 79 | 80 | impl ListModelImpl for Devices { 81 | fn item_type(&self) -> glib::Type { 82 | Device::static_type() 83 | } 84 | 85 | fn n_items(&self) -> u32 { 86 | self.registered_devices.n_items() + self.discovered_devices.n_items() 87 | } 88 | 89 | fn item(&self, position: u32) -> Option { 90 | if position < self.registered_devices.n_items() { 91 | self.registered_devices.item(position) 92 | } else { 93 | self.discovered_devices 94 | .item(position - self.registered_devices.n_items()) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/searchprovider.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Utilities for the search provider of Turn On. 8 | 9 | use glib::{ControlFlow, Variant, VariantDict, dpgettext2}; 10 | use gtk::gio::{ 11 | DBusConnection, DBusError, ListStore, Notification, NotificationPriority, RegistrationId, 12 | }; 13 | use gtk::prelude::*; 14 | 15 | use crate::app::TurnOnApplication; 16 | use crate::config::G_LOG_DOMAIN; 17 | use crate::dbus::searchprovider2::{self, ActivateResult, GetResultMetas, MethodCall}; 18 | 19 | use super::model::Device; 20 | 21 | fn matches_terms>(device: &Device, terms: &[S]) -> bool { 22 | let label = device.label().to_lowercase(); 23 | let host = device.host().to_lowercase(); 24 | terms.iter().all(|term| { 25 | let term = term.as_ref().to_lowercase(); 26 | label.contains(&term) || host.contains(&term) 27 | }) 28 | } 29 | 30 | /// Get a result set. 31 | /// 32 | /// Return the id of all devices which match all of `terms`, either in their 33 | /// label or their host. 34 | /// 35 | /// The ID of a device is simply the stringified position in the list of devices. 36 | fn get_ids_for_terms>(devices: &ListStore, terms: &[S]) -> Vec { 37 | devices 38 | .into_iter() 39 | .map(|obj| obj.unwrap().downcast::().unwrap()) 40 | // Enumerate first so that the index is correct 41 | .enumerate() 42 | .filter(|(_, device)| matches_terms(device, terms)) 43 | .map(|(i, _)| i.to_string()) 44 | .collect::>() 45 | } 46 | 47 | fn get_result_set>(app: &TurnOnApplication, terms: &[S]) -> Variant { 48 | let results = get_ids_for_terms(&app.devices().registered_devices(), terms); 49 | (results,).into() 50 | } 51 | 52 | async fn activate_result( 53 | app: &TurnOnApplication, 54 | call: ActivateResult, 55 | ) -> Result, glib::Error> { 56 | let device = call 57 | .identifier 58 | .parse::() 59 | .ok() 60 | .and_then(|n| app.devices().registered_devices().item(n)) 61 | .map(|o| o.downcast::().unwrap()); 62 | glib::trace!( 63 | "Activating device at index {}, device found? {}", 64 | call.identifier, 65 | device.is_some() 66 | ); 67 | match device { 68 | None => { 69 | glib::warn!("Failed to find device with id {}", call.identifier); 70 | Ok(None) 71 | } 72 | Some(device) => { 73 | if let Ok(()) = device.wol().await { 74 | let notification = Notification::new(&dpgettext2( 75 | None, 76 | "search-provider.notification.title", 77 | "Sent magic packet", 78 | )); 79 | notification.set_body(Some( 80 | &dpgettext2( 81 | None, 82 | "search-provider.notification.body", 83 | "Sent magic packet to mac address %1 of device %2.", 84 | ) 85 | .replace("%1", &device.mac_address().to_string()) 86 | .replace("%2", &device.label()), 87 | )); 88 | let id = glib::uuid_string_random(); 89 | app.send_notification(Some(&id), ¬ification); 90 | glib::timeout_add_seconds_local( 91 | 10, 92 | glib::clone!( 93 | #[weak] 94 | app, 95 | #[upgrade_or] 96 | ControlFlow::Break, 97 | move || { 98 | app.withdraw_notification(&id); 99 | ControlFlow::Break 100 | } 101 | ), 102 | ); 103 | } else { 104 | let notification = Notification::new(&dpgettext2( 105 | None, 106 | "search-provider.notification.title", 107 | "Failed to send magic packet", 108 | )); 109 | notification.set_body(Some( 110 | &dpgettext2( 111 | None, 112 | "search-provider.notification.body", 113 | "Failed to send magic packet to mac address %1 of device %2.", 114 | ) 115 | .replace("%1", &device.mac_address().to_string()) 116 | .replace("%2", &device.label()), 117 | )); 118 | notification.set_priority(NotificationPriority::Urgent); 119 | app.send_notification(None, ¬ification); 120 | } 121 | Ok(None) 122 | } 123 | } 124 | } 125 | 126 | fn get_result_metas(app: &TurnOnApplication, call: &GetResultMetas) -> Variant { 127 | let metas: Vec = call 128 | .identifiers 129 | .iter() 130 | .filter_map(|id| { 131 | id.parse::() 132 | .ok() 133 | .and_then(|n| app.devices().registered_devices().item(n)) 134 | .map(|obj| { 135 | let device = obj.downcast::().unwrap(); 136 | let metas = VariantDict::new(None); 137 | metas.insert("id", id); 138 | metas.insert("name", device.label()); 139 | metas.insert("description", device.host()); 140 | metas 141 | }) 142 | }) 143 | .collect::>(); 144 | (metas,).into() 145 | } 146 | 147 | async fn dispatch_method_call( 148 | app: Option, 149 | call: MethodCall, 150 | ) -> Result, glib::Error> { 151 | use MethodCall::*; 152 | let app = 153 | app.ok_or_else(|| glib::Error::new(DBusError::Disconnected, "Application is gone"))?; 154 | match call { 155 | GetInitialResultSet(c) => { 156 | glib::trace!("Initial search for terms {:?}", c.terms); 157 | Ok(Some(get_result_set(&app, c.terms.as_slice()))) 158 | } 159 | GetSubsearchResultSet(c) => { 160 | glib::trace!( 161 | "Sub-search for terms {:?}, with initial results {:?}", 162 | c.terms, 163 | c.previous_results 164 | ); 165 | // We just search fresh again, since our model is neither that big nor that complicated 166 | Ok(Some(get_result_set(&app, c.terms.as_slice()))) 167 | } 168 | GetResultMetas(c) => Ok(Some(get_result_metas(&app, &c))), 169 | ActivateResult(c) => activate_result(&app, c).await, 170 | LaunchSearch(c) => { 171 | glib::debug!("Launching search for terms {:?}", &c.terms); 172 | // We don't have in-app search (yet?) so let's just raise our main window 173 | app.activate(); 174 | Ok(None) 175 | } 176 | } 177 | } 178 | 179 | /// Register the Turn On search provider for `app`. 180 | /// 181 | /// Register a search provider for devices on the D-Bus connection of `app`. 182 | /// The search provider exposes devices from the `app` model to GNOME Shell, 183 | /// and allows to turn on devices directly from the shell overview. 184 | pub fn register_app_search_provider( 185 | connection: &DBusConnection, 186 | app: &TurnOnApplication, 187 | ) -> Result { 188 | let interface_info = searchprovider2::interface(); 189 | let registration_id = connection 190 | .register_object("/de/swsnr/turnon/search", &interface_info) 191 | .typed_method_call::() 192 | .invoke_and_return_future_local(glib::clone!( 193 | #[weak_allow_none] 194 | app, 195 | move |_, sender, call| { 196 | glib::debug!("Sender {sender:?} called method {call:?}"); 197 | dispatch_method_call(app.clone(), call) 198 | } 199 | )) 200 | .build()?; 201 | Ok(registration_id) 202 | } 203 | 204 | #[cfg(test)] 205 | mod tests { 206 | use macaddr::MacAddr6; 207 | 208 | use crate::app::model::Device; 209 | 210 | use super::*; 211 | 212 | #[test] 213 | fn device_matches_terms_case_insensitive() { 214 | let device = Device::new("Server", MacAddr6::nil().into(), "foo.example.com"); 215 | assert!(matches_terms(&device, &["server"])); 216 | assert!(matches_terms(&device, &["SERVER"])); 217 | assert!(matches_terms(&device, &["SeRvEr"])); 218 | assert!(matches_terms(&device, &["FOO"])); 219 | assert!(matches_terms(&device, &["fOo"])); 220 | } 221 | 222 | #[test] 223 | fn device_matches_terms_in_label_and_host() { 224 | let device = Device::new("Server", MacAddr6::nil().into(), "foo.example.com"); 225 | assert!(matches_terms(&device, &["Server", "foo"])); 226 | } 227 | 228 | #[test] 229 | fn device_matches_terms_ignores_mac_address() { 230 | let device = Device::new( 231 | "Server", 232 | "a2:35:e4:9e:b4:c3".parse().unwrap(), 233 | "foo.example.com", 234 | ); 235 | assert!(!matches_terms(&device, &["a2:35"])); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/app/storage.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::fs::File; 8 | use std::io::{ErrorKind, Result}; 9 | use std::panic::resume_unwind; 10 | use std::path::{Path, PathBuf}; 11 | 12 | use async_channel::{Receiver, Sender}; 13 | use glib::object::Cast; 14 | use gtk::gio::ListStore; 15 | use macaddr::MacAddr6; 16 | use serde::{Deserialize, Serialize}; 17 | 18 | use crate::config::G_LOG_DOMAIN; 19 | use crate::net::MacAddr6Boxed; 20 | 21 | use super::model::Device; 22 | 23 | /// A stored device. 24 | /// 25 | /// Like [`model::Device`], but for serialization. 26 | #[derive(Debug, Serialize, Deserialize)] 27 | pub struct StoredDevice { 28 | pub label: String, 29 | #[serde(with = "mac_addr6_as_string")] 30 | pub mac_address: MacAddr6, 31 | pub host: String, 32 | } 33 | 34 | impl From for Device { 35 | fn from(value: StoredDevice) -> Self { 36 | Device::new( 37 | &value.label, 38 | MacAddr6Boxed::from(value.mac_address), 39 | &value.host, 40 | ) 41 | } 42 | } 43 | 44 | impl From for StoredDevice { 45 | fn from(device: Device) -> Self { 46 | StoredDevice { 47 | label: device.label(), 48 | host: device.host(), 49 | mac_address: *device.mac_address(), 50 | } 51 | } 52 | } 53 | 54 | mod mac_addr6_as_string { 55 | use std::str::FromStr; 56 | 57 | use macaddr::MacAddr6; 58 | use serde::{Deserialize, Deserializer, Serializer}; 59 | 60 | #[allow( 61 | clippy::trivially_copy_pass_by_ref, 62 | reason = "serde's with requires a &T type here" 63 | )] 64 | pub fn serialize(addr: &MacAddr6, serializer: S) -> Result 65 | where 66 | S: Serializer, 67 | { 68 | serializer.serialize_str(&addr.to_string()) 69 | } 70 | 71 | pub fn deserialize<'de, D>(deserializer: D) -> Result 72 | where 73 | D: Deserializer<'de>, 74 | { 75 | MacAddr6::from_str(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) 76 | } 77 | } 78 | 79 | fn read_devices(target: &Path) -> Result> { 80 | File::open(target).and_then(|source| { 81 | serde_json::from_reader(source) 82 | .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)) 83 | }) 84 | } 85 | 86 | fn write_devices(target: &Path, devices: &[StoredDevice]) -> Result<()> { 87 | let target_directory = target.parent().ok_or(std::io::Error::new( 88 | ErrorKind::InvalidData, 89 | format!("Path {} must be absolute, but wasn't!", target.display()), 90 | ))?; 91 | std::fs::create_dir_all(target_directory)?; 92 | File::create(target).and_then(|sink| { 93 | serde_json::to_writer_pretty(sink, &devices) 94 | .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)) 95 | }) 96 | } 97 | 98 | async fn handle_save_requests(data_file: PathBuf, rx: Receiver>) { 99 | loop { 100 | if let Ok(devices) = rx.recv().await { 101 | let target = data_file.clone(); 102 | // Off-load serialization and writing to gio's blocking pool. We 103 | // then wait for the result of saving the file before processing 104 | // the next storage request, to avoid writing to the same file 105 | // in parallel. 106 | let result = 107 | gtk::gio::spawn_blocking(move || write_devices(&target, devices.as_slice())).await; 108 | match result { 109 | Err(payload) => { 110 | resume_unwind(payload); 111 | } 112 | Ok(Err(error)) => { 113 | glib::error!( 114 | "Failed to save devices to {}: {}", 115 | data_file.display(), 116 | error 117 | ); 118 | } 119 | Ok(Ok(())) => { 120 | glib::info!("Saved devices to {}", data_file.display()); 121 | } 122 | } 123 | } else { 124 | glib::warn!("Channel closed"); 125 | break; 126 | } 127 | } 128 | } 129 | 130 | /// A service which can save devices. 131 | /// 132 | /// This service processes requests to save a serialized list of devices to a 133 | /// file. 134 | /// 135 | /// It allows many concurrent save requests, but always discards all but the 136 | /// latest save requests, to avoid redundant writes to the file. 137 | #[derive(Debug)] 138 | pub struct StorageService { 139 | target: PathBuf, 140 | tx: Sender>, 141 | rx: Receiver>, 142 | } 143 | 144 | impl StorageService { 145 | /// Create a new storage service for the given `target` file. 146 | pub fn new(target: PathBuf) -> Self { 147 | // Create a bounded channel which can only hold a single request at a time. 148 | // Then we can use force_send to overwrite earlier storage requests to avoid 149 | // redundant writes. 150 | let (tx, rx) = async_channel::bounded::>(1); 151 | Self { target, tx, rx } 152 | } 153 | 154 | /// Get the target path for storage. 155 | pub fn target(&self) -> &Path { 156 | &self.target 157 | } 158 | 159 | /// Get a client for this service. 160 | pub fn client(&self) -> StorageServiceClient { 161 | StorageServiceClient { 162 | tx: self.tx.clone(), 163 | } 164 | } 165 | 166 | /// Load devices synchronously from storage. 167 | pub fn load_sync(&self) -> Result> { 168 | read_devices(&self.target) 169 | } 170 | 171 | /// Spawn the service. 172 | /// 173 | /// Consumes the service to ensure that only a single service instance is 174 | /// running. 175 | /// 176 | /// After spawning no further clients can be created from the service. You 177 | /// must create a client first, and then clone that client. 178 | pub async fn spawn(self) { 179 | handle_save_requests(self.target, self.rx).await; 180 | } 181 | } 182 | 183 | /// A storage client which can request saving devices from a storage service. 184 | /// 185 | /// The client is cheap to clone. 186 | #[derive(Debug, Clone)] 187 | pub struct StorageServiceClient { 188 | tx: Sender>, 189 | } 190 | 191 | impl StorageServiceClient { 192 | /// Request that the service save all devices in the given device `model`. 193 | pub fn request_save_device_store(&self, model: &ListStore) { 194 | self.request_save_devices( 195 | model 196 | .into_iter() 197 | .filter_map(|obj| obj.unwrap().downcast::().ok()) 198 | .map(StoredDevice::from) 199 | .collect(), 200 | ); 201 | } 202 | 203 | /// Request that the service save the given `devices`. 204 | pub fn request_save_devices(&self, devices: Vec) { 205 | // Forcibly overwrite earlier storage requests, to ensure we only store 206 | // the most recent version of data. 207 | self.tx.force_send(devices).unwrap(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/app/widgets.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | mod application_window; 8 | mod device_row; 9 | mod edit_device_dialog; 10 | mod validation_indicator; 11 | 12 | pub use application_window::TurnOnApplicationWindow; 13 | pub use device_row::{DeviceRow, MoveDirection}; 14 | pub use edit_device_dialog::EditDeviceDialog; 15 | pub use validation_indicator::ValidationIndicator; 16 | -------------------------------------------------------------------------------- /src/app/widgets/device_row.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use glib::object::ObjectExt; 8 | use gtk::glib; 9 | 10 | pub use self::r#enum::MoveDirection; 11 | use super::super::model::Device; 12 | 13 | #[allow(clippy::as_conversions, reason = "Comes from glib::Enum")] 14 | mod r#enum { 15 | /// The direction a device was moved into. 16 | #[derive(Debug, Clone, Copy, Eq, PartialEq, glib::Enum)] 17 | #[enum_type(name = "DeviceMoveDirection")] 18 | pub enum MoveDirection { 19 | /// The device was moved upwards. 20 | Upwards, 21 | /// The device was moved downwards. 22 | Downwards, 23 | } 24 | } 25 | 26 | glib::wrapper! { 27 | pub struct DeviceRow(ObjectSubclass) 28 | @extends adw::ActionRow, adw::PreferencesRow, gtk::ListBox, gtk::Widget, 29 | @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; 30 | } 31 | 32 | impl DeviceRow { 33 | pub fn new(device: &Device) -> Self { 34 | glib::Object::builder() 35 | .property("device", device) 36 | .property("is-device-online", false) 37 | .build() 38 | } 39 | 40 | pub fn connect_deleted(&self, callback: F) -> glib::SignalHandlerId 41 | where 42 | F: Fn(&Self, &Device) + 'static, 43 | { 44 | self.connect_local( 45 | "deleted", 46 | false, 47 | glib::clone!( 48 | #[weak(rename_to=row)] 49 | &self, 50 | #[upgrade_or_default] 51 | move |args| { 52 | let device = args 53 | .get(1) 54 | .expect("'deleted' signal expected one argument but got none!") 55 | .get() 56 | .unwrap_or_else(|error| { 57 | panic!("'deleted' signal expected Device as first argument: {error}"); 58 | }); 59 | callback(&row, device); 60 | None 61 | } 62 | ), 63 | ) 64 | } 65 | 66 | pub fn connect_added(&self, callback: F) -> glib::SignalHandlerId 67 | where 68 | F: Fn(&Self, &Device) + 'static, 69 | { 70 | self.connect_local( 71 | "added", 72 | false, 73 | glib::clone!( 74 | #[weak(rename_to=row)] 75 | &self, 76 | #[upgrade_or_default] 77 | move |args| { 78 | let device = args 79 | .get(1) 80 | .expect("'added' signal expects one argument but got none?") 81 | .get() 82 | .unwrap_or_else(|error| { 83 | panic!("'added' signal expected Device as first argument: {error}"); 84 | }); 85 | callback(&row, device); 86 | None 87 | } 88 | ), 89 | ) 90 | } 91 | 92 | pub fn connect_moved(&self, callback: F) -> glib::SignalHandlerId 93 | where 94 | F: Fn(&Self, &Device, MoveDirection) + 'static, 95 | { 96 | self.connect_local( 97 | "moved", 98 | false, 99 | glib::clone!( 100 | #[weak(rename_to=row)] 101 | &self, 102 | #[upgrade_or_default] 103 | move |args| { 104 | let device = args 105 | .get(1) 106 | .expect("'moved' signal expected two arguments but got none") 107 | .get() 108 | .unwrap_or_else(|error| { 109 | panic!("'moved' signal expected Device as first argument: {error}"); 110 | }); 111 | let direction = args 112 | .get(2) 113 | .expect("'moved' signal expected two arguments but got only one") 114 | .get() 115 | .unwrap_or_else(|error| { 116 | panic!( 117 | "'moved' signal expected MoveDirection as second argument: {error}" 118 | ); 119 | }); 120 | callback(&row, device, direction); 121 | None 122 | } 123 | ), 124 | ) 125 | } 126 | } 127 | 128 | impl Default for DeviceRow { 129 | fn default() -> Self { 130 | glib::Object::builder().build() 131 | } 132 | } 133 | 134 | mod imp { 135 | use std::cell::{Cell, RefCell}; 136 | use std::sync::LazyLock; 137 | 138 | use adw::prelude::*; 139 | use adw::subclass::prelude::*; 140 | use glib::Properties; 141 | use glib::subclass::{InitializingObject, Signal}; 142 | use gtk::gdk::{Key, ModifierType}; 143 | use gtk::{CompositeTemplate, template_callbacks}; 144 | 145 | use crate::app::model::Device; 146 | 147 | use super::super::EditDeviceDialog; 148 | use super::MoveDirection; 149 | 150 | #[derive(CompositeTemplate, Properties)] 151 | #[properties(wrapper_type = super::DeviceRow)] 152 | #[template(resource = "/de/swsnr/turnon/ui/device-row.ui")] 153 | pub struct DeviceRow { 154 | #[property(get, set)] 155 | device: RefCell, 156 | #[property(get, set)] 157 | is_device_online: Cell, 158 | #[property(get, set, nullable)] 159 | device_url: RefCell>, 160 | #[property(get)] 161 | suffix_mode: RefCell, 162 | } 163 | 164 | #[template_callbacks] 165 | impl DeviceRow { 166 | #[template_callback(function)] 167 | pub fn device_mac_address(device: &Device) -> String { 168 | device.mac_address().to_string() 169 | } 170 | 171 | #[template_callback(function)] 172 | pub fn device_state_name(is_device_online: bool) -> &'static str { 173 | if is_device_online { 174 | "online" 175 | } else { 176 | "offline" 177 | } 178 | } 179 | 180 | #[template_callback(function)] 181 | pub fn device_host(host: &str, url: Option<&str>) -> String { 182 | if let Some(url) = url { 183 | format!( 184 | "{}", 185 | glib::markup_escape_text(url), 186 | glib::markup_escape_text(host) 187 | ) 188 | } else { 189 | glib::markup_escape_text(host).to_string() 190 | } 191 | } 192 | 193 | pub fn set_suffix_mode(&self, mode: &str) { 194 | self.suffix_mode.replace(mode.to_owned()); 195 | self.obj().notify_suffix_mode(); 196 | } 197 | } 198 | 199 | #[glib::object_subclass] 200 | impl ObjectSubclass for DeviceRow { 201 | const NAME: &'static str = "DeviceRow"; 202 | 203 | type Type = super::DeviceRow; 204 | 205 | type ParentType = adw::ActionRow; 206 | 207 | fn class_init(klass: &mut Self::Class) { 208 | klass.bind_template(); 209 | klass.bind_template_callbacks(); 210 | 211 | klass.install_action("row.move-up", None, |row, _, _| { 212 | row.emit_by_name::<()>("moved", &[&row.device(), &MoveDirection::Upwards]); 213 | }); 214 | klass.install_action("row.move-down", None, |row, _, _| { 215 | row.emit_by_name::<()>("moved", &[&row.device(), &MoveDirection::Downwards]); 216 | }); 217 | klass.install_action("row.ask-delete", None, |row, _, _| { 218 | row.imp().set_suffix_mode("confirm-delete"); 219 | }); 220 | klass.install_action("row.cancel-delete", None, |row, _, _| { 221 | row.imp().set_suffix_mode("buttons"); 222 | }); 223 | klass.install_action("row.delete", None, |row, _, _| { 224 | row.emit_by_name::<()>("deleted", &[&row.device()]); 225 | }); 226 | klass.install_action("row.edit", None, |obj, _, _| { 227 | let dialog = EditDeviceDialog::edit(obj.device()); 228 | dialog.present(Some(obj)); 229 | }); 230 | klass.install_action("row.add", None, |row, _, _| { 231 | // Create a fresh device, edit it, and then emit an added signal 232 | // if the user saves the device. 233 | let current_device = row.device(); 234 | let dialog = EditDeviceDialog::edit(Device::new( 235 | ¤t_device.label(), 236 | current_device.mac_address(), 237 | ¤t_device.host(), 238 | )); 239 | dialog.connect_saved(glib::clone!( 240 | #[weak] 241 | row, 242 | move |_, device| row.emit_by_name::<()>("added", &[device]) 243 | )); 244 | dialog.present(Some(row)); 245 | }); 246 | 247 | klass.add_binding_action(Key::Up, ModifierType::ALT_MASK, "row.move-up"); 248 | klass.add_binding_action(Key::Down, ModifierType::ALT_MASK, "row.move-down"); 249 | klass.add_binding_action(Key::Return, ModifierType::ALT_MASK, "row.edit"); 250 | klass.add_binding_action(Key::N, ModifierType::CONTROL_MASK, "row.add"); 251 | klass.add_binding_action( 252 | Key::Delete, 253 | ModifierType::NO_MODIFIER_MASK, 254 | "row.ask-delete", 255 | ); 256 | klass.add_binding_action(Key::Delete, ModifierType::CONTROL_MASK, "row.delete"); 257 | } 258 | 259 | fn instance_init(obj: &InitializingObject) { 260 | obj.init_template(); 261 | } 262 | 263 | fn new() -> Self { 264 | Self { 265 | device: RefCell::default(), 266 | is_device_online: Cell::default(), 267 | device_url: RefCell::default(), 268 | suffix_mode: RefCell::new("buttons".into()), 269 | } 270 | } 271 | } 272 | 273 | #[glib::derived_properties] 274 | impl ObjectImpl for DeviceRow { 275 | fn signals() -> &'static [Signal] { 276 | static SIGNALS: LazyLock> = LazyLock::new(|| { 277 | vec![ 278 | Signal::builder("deleted") 279 | .action() 280 | .param_types([Device::static_type()]) 281 | .build(), 282 | Signal::builder("added") 283 | .action() 284 | .param_types([Device::static_type()]) 285 | .build(), 286 | Signal::builder("moved") 287 | .action() 288 | .param_types([Device::static_type(), MoveDirection::static_type()]) 289 | .build(), 290 | ] 291 | }); 292 | SIGNALS.as_ref() 293 | } 294 | 295 | fn constructed(&self) { 296 | self.parent_constructed(); 297 | } 298 | } 299 | 300 | impl WidgetImpl for DeviceRow {} 301 | 302 | impl ListBoxRowImpl for DeviceRow {} 303 | 304 | impl PreferencesRowImpl for DeviceRow {} 305 | 306 | impl ActionRowImpl for DeviceRow {} 307 | } 308 | -------------------------------------------------------------------------------- /src/app/widgets/edit_device_dialog.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use glib::clone; 8 | use gtk::{glib, prelude::ObjectExt}; 9 | 10 | use crate::app::model::Device; 11 | 12 | glib::wrapper! { 13 | pub struct EditDeviceDialog(ObjectSubclass) 14 | @extends adw::Dialog, gtk::Widget, 15 | @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; 16 | } 17 | 18 | impl EditDeviceDialog { 19 | /// Create a new dialog to edit a new device. 20 | pub fn new() -> Self { 21 | glib::Object::builder().build() 22 | } 23 | 24 | /// Create a new dialog the edit an existing device. 25 | pub fn edit(device: Device) -> Self { 26 | glib::Object::builder() 27 | .property("device", Some(device)) 28 | .build() 29 | } 30 | 31 | pub fn connect_saved(&self, callback: F) -> glib::SignalHandlerId 32 | where 33 | F: Fn(&Self, &Device) + 'static, 34 | { 35 | self.connect_local( 36 | "saved", 37 | false, 38 | clone!( 39 | #[weak(rename_to=dialog)] 40 | &self, 41 | #[upgrade_or_default] 42 | move |args| { 43 | let device = args 44 | .get(1) 45 | .expect("'saved' signal expects one argument but got none?") 46 | .get() 47 | .unwrap_or_else(|error| { 48 | panic!("'saved' signal expected Device as first argument: {error}"); 49 | }); 50 | callback(&dialog, device); 51 | None 52 | } 53 | ), 54 | ) 55 | } 56 | } 57 | 58 | impl Default for EditDeviceDialog { 59 | fn default() -> Self { 60 | Self::new() 61 | } 62 | } 63 | 64 | mod imp { 65 | 66 | use std::cell::{Cell, RefCell}; 67 | use std::net::IpAddr; 68 | use std::str::FromStr; 69 | use std::sync::LazyLock; 70 | 71 | use adw::prelude::*; 72 | use adw::subclass::prelude::*; 73 | use gtk::CompositeTemplate; 74 | use gtk::gdk::{Key, ModifierType}; 75 | use gtk::glib::Properties; 76 | use gtk::glib::subclass::{InitializingObject, Signal}; 77 | use gtk::{glib, template_callbacks}; 78 | 79 | use crate::app::model::Device; 80 | use crate::net::MacAddr6Boxed; 81 | 82 | use super::super::ValidationIndicator; 83 | 84 | /// Whether `s` looks as if it's a host and port, e.g. `localhost:1245`. 85 | fn is_host_and_port(s: &str) -> bool { 86 | if let Some((_, port)) = s.rsplit_once(':') { 87 | port.chars().all(|c| c.is_ascii_digit()) 88 | } else { 89 | false 90 | } 91 | } 92 | 93 | #[derive(CompositeTemplate, Properties)] 94 | #[template(resource = "/de/swsnr/turnon/ui/edit-device-dialog.ui")] 95 | #[properties(wrapper_type = super::EditDeviceDialog)] 96 | pub struct EditDeviceDialog { 97 | #[property(get, set, construct_only)] 98 | pub device: RefCell>, 99 | #[property(get, set)] 100 | pub label: RefCell, 101 | #[property(get)] 102 | pub label_valid: Cell, 103 | #[property(get, set)] 104 | pub mac_address: RefCell, 105 | #[property(get)] 106 | pub mac_address_valid: Cell, 107 | #[property(get, set)] 108 | pub host: RefCell, 109 | #[property(get, default = "invalid-empty")] 110 | pub host_indicator: RefCell, 111 | #[property(get = Self::is_valid, default = false, type = bool)] 112 | pub is_valid: (), 113 | } 114 | 115 | #[template_callbacks] 116 | impl EditDeviceDialog { 117 | fn is_label_valid(&self) -> bool { 118 | !self.label.borrow().is_empty() 119 | } 120 | 121 | fn validate_label(&self) { 122 | self.label_valid.set(self.is_label_valid()); 123 | self.obj().notify_label_valid(); 124 | self.obj().notify_is_valid(); 125 | } 126 | 127 | fn is_mac_address_valid(&self) -> bool { 128 | let text = self.mac_address.borrow(); 129 | !text.is_empty() && macaddr::MacAddr::from_str(&text).is_ok() 130 | } 131 | 132 | fn validate_mac_address(&self) { 133 | self.mac_address_valid.set(self.is_mac_address_valid()); 134 | self.obj().notify_mac_address_valid(); 135 | self.obj().notify_is_valid(); 136 | } 137 | 138 | fn validate_host(&self) { 139 | let host = self.host.borrow(); 140 | let indicator = match IpAddr::from_str(&host) { 141 | Ok(IpAddr::V4(..)) => "ipv4", 142 | Ok(IpAddr::V6(..)) => "ipv6", 143 | Err(_) => { 144 | if host.is_empty() { 145 | "invalid-empty" 146 | } else if is_host_and_port(&host) { 147 | // Check whether the user specified a port, and if so, 148 | // reject the input. 149 | // 150 | // See https://github.com/swsnr/turnon/issues/40 151 | "invalid-socket-address" 152 | } else { 153 | "host" 154 | } 155 | } 156 | }; 157 | self.host_indicator.replace(indicator.to_owned()); 158 | self.obj().notify_host_indicator(); 159 | self.obj().notify_is_valid(); 160 | } 161 | 162 | fn host_valid(&self) -> bool { 163 | !self.host_indicator.borrow().starts_with("invalid-") 164 | } 165 | 166 | fn validate_all(&self) { 167 | self.validate_label(); 168 | self.validate_mac_address(); 169 | self.validate_host(); 170 | } 171 | 172 | fn is_valid(&self) -> bool { 173 | self.label_valid.get() && self.mac_address_valid.get() && self.host_valid() 174 | } 175 | 176 | #[template_callback] 177 | fn move_to_next_entry(entry: &adw::EntryRow) { 178 | entry.emit_move_focus(gtk::DirectionType::TabForward); 179 | } 180 | } 181 | 182 | #[glib::object_subclass] 183 | impl ObjectSubclass for EditDeviceDialog { 184 | const NAME: &'static str = "EditDeviceDialog"; 185 | 186 | type Type = super::EditDeviceDialog; 187 | 188 | type ParentType = adw::Dialog; 189 | 190 | fn new() -> Self { 191 | Self { 192 | device: RefCell::default(), 193 | label: RefCell::default(), 194 | label_valid: Cell::default(), 195 | mac_address: RefCell::default(), 196 | mac_address_valid: Cell::default(), 197 | host: RefCell::default(), 198 | host_indicator: RefCell::new("invalid-empty".to_string()), 199 | is_valid: (), 200 | } 201 | } 202 | 203 | fn class_init(klass: &mut Self::Class) { 204 | ValidationIndicator::ensure_type(); 205 | Device::ensure_type(); 206 | 207 | klass.bind_template(); 208 | klass.bind_template_callbacks(); 209 | 210 | klass.install_action("device.save", None, |dialog, _, _| { 211 | if dialog.is_valid() { 212 | // At this point we know that the MAC address is valid, hence we can unwrap 213 | let mac_address = MacAddr6Boxed::from_str(&dialog.mac_address()).unwrap(); 214 | let device = match dialog.device() { 215 | Some(device) => { 216 | // The dialog edits an existing device, so update its fields. 217 | device.set_label(dialog.label()); 218 | device.set_mac_address(mac_address); 219 | device.set_host(dialog.host()); 220 | device 221 | } 222 | None => { 223 | // Create a new device if the dialog does not own a device. 224 | Device::new(&dialog.label(), mac_address, &dialog.host()) 225 | } 226 | }; 227 | dialog.emit_by_name::<()>("saved", &[&device]); 228 | dialog.close(); 229 | } 230 | }); 231 | 232 | klass.add_binding_action(Key::S, ModifierType::CONTROL_MASK, "device.save"); 233 | } 234 | 235 | fn instance_init(obj: &InitializingObject) { 236 | obj.init_template(); 237 | } 238 | } 239 | 240 | #[glib::derived_properties] 241 | impl ObjectImpl for EditDeviceDialog { 242 | fn signals() -> &'static [Signal] { 243 | static SIGNALS: LazyLock> = LazyLock::new(|| { 244 | vec![ 245 | Signal::builder("saved") 246 | .action() 247 | .param_types([Device::static_type()]) 248 | .build(), 249 | ] 250 | }); 251 | SIGNALS.as_ref() 252 | } 253 | 254 | fn constructed(&self) { 255 | self.parent_constructed(); 256 | if let Some(device) = self.obj().device() { 257 | // Initialize properties from device 258 | self.obj().set_label(device.label()); 259 | self.obj().set_mac_address(device.mac_address().to_string()); 260 | self.obj().set_host(device.host()); 261 | } 262 | // After initialization, update validation status. 263 | self.validate_all(); 264 | self.obj().action_set_enabled("device.save", false); 265 | self.obj().connect_label_notify(|dialog| { 266 | dialog.imp().validate_label(); 267 | }); 268 | self.obj().connect_mac_address_notify(|dialog| { 269 | dialog.imp().validate_mac_address(); 270 | }); 271 | self.obj().connect_host_notify(|dialog| { 272 | dialog.imp().validate_host(); 273 | }); 274 | self.obj().connect_is_valid_notify(|dialog| { 275 | dialog.action_set_enabled("device.save", dialog.is_valid()); 276 | }); 277 | } 278 | } 279 | 280 | impl WidgetImpl for EditDeviceDialog {} 281 | 282 | impl AdwDialogImpl for EditDeviceDialog {} 283 | } 284 | -------------------------------------------------------------------------------- /src/app/widgets/validation_indicator.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use gtk::glib; 8 | 9 | glib::wrapper! { 10 | pub struct ValidationIndicator(ObjectSubclass) 11 | @extends adw::Bin, gtk::Widget, 12 | @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; 13 | } 14 | 15 | impl ValidationIndicator { 16 | /// Create a new dialog to add a device. 17 | pub fn new() -> Self { 18 | glib::Object::builder().build() 19 | } 20 | } 21 | 22 | impl Default for ValidationIndicator { 23 | fn default() -> Self { 24 | Self::new() 25 | } 26 | } 27 | 28 | mod imp { 29 | use std::cell::{Cell, RefCell}; 30 | 31 | use adw::subclass::prelude::*; 32 | use gtk::CompositeTemplate; 33 | use gtk::glib; 34 | use gtk::glib::Properties; 35 | use gtk::glib::prelude::*; 36 | use gtk::glib::subclass::InitializingObject; 37 | 38 | #[derive(CompositeTemplate, Default, Properties)] 39 | #[template(resource = "/de/swsnr/turnon/ui/validation-indicator.ui")] 40 | #[properties(wrapper_type = super::ValidationIndicator)] 41 | pub struct ValidationIndicator { 42 | #[property(get, set)] 43 | is_valid: Cell, 44 | #[property(get, set)] 45 | feedback: RefCell, 46 | #[template_child] 47 | indicator: TemplateChild, 48 | #[template_child] 49 | invalid: TemplateChild, 50 | #[template_child] 51 | valid: TemplateChild, 52 | } 53 | 54 | impl ValidationIndicator { 55 | fn update(&self) { 56 | let child = if self.is_valid.get() { 57 | self.valid.get() 58 | } else { 59 | self.invalid.get() 60 | }; 61 | self.indicator.set_visible_child(&child); 62 | } 63 | } 64 | 65 | #[glib::object_subclass] 66 | impl ObjectSubclass for ValidationIndicator { 67 | const NAME: &'static str = "ValidationIndicator"; 68 | 69 | type Type = super::ValidationIndicator; 70 | 71 | type ParentType = adw::Bin; 72 | 73 | fn class_init(klass: &mut Self::Class) { 74 | klass.bind_template(); 75 | } 76 | 77 | fn instance_init(obj: &InitializingObject) { 78 | obj.init_template(); 79 | } 80 | } 81 | 82 | #[glib::derived_properties] 83 | impl ObjectImpl for ValidationIndicator { 84 | fn constructed(&self) { 85 | self.parent_constructed(); 86 | self.update(); 87 | self.obj().connect_is_valid_notify(|o| { 88 | o.imp().update(); 89 | }); 90 | } 91 | } 92 | 93 | impl WidgetImpl for ValidationIndicator {} 94 | 95 | impl BinImpl for ValidationIndicator {} 96 | } 97 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use glib::{GStr, gstr}; 8 | use gtk::gio; 9 | 10 | /// The app ID to use. 11 | pub const APP_ID: &GStr = gstr!("de.swsnr.turnon"); 12 | 13 | /// The Cargo package verson. 14 | /// 15 | /// This provides the full version from `Cargo.toml`. 16 | pub const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); 17 | 18 | /// Get [`CARGO_PKG_VERSION`] parsed. 19 | fn cargo_pkg_version() -> semver::Version { 20 | semver::Version::parse(CARGO_PKG_VERSION).unwrap() 21 | } 22 | 23 | /// The version to use for release notes. 24 | /// 25 | /// Returns [`CARGO_PKG_VERSION`] but with patch set to 0, and all pre and 26 | /// build parts emptied. 27 | /// 28 | /// This follows our versioning policy which uses patch releases only for 29 | /// translation updates. 30 | pub fn release_notes_version() -> semver::Version { 31 | let mut version = cargo_pkg_version(); 32 | version.patch = 0; 33 | version.pre = semver::Prerelease::EMPTY; 34 | version.build = semver::BuildMetadata::EMPTY; 35 | version 36 | } 37 | 38 | pub const G_LOG_DOMAIN: &str = "TurnOn"; 39 | 40 | /// Whether the app is running in flatpak. 41 | pub fn running_in_flatpak() -> bool { 42 | std::fs::exists("/.flatpak-info").unwrap_or_default() 43 | } 44 | 45 | /// Whether this is a development/nightly build. 46 | pub fn is_development() -> bool { 47 | APP_ID.ends_with(".Devel") 48 | } 49 | 50 | /// Get a schema source for this application. 51 | /// 52 | /// In a debug build load compiled schemas from the manifest directory, to allow 53 | /// running the application uninstalled. 54 | /// 55 | /// In a release build only use the default schema source. 56 | pub fn schema_source() -> gio::SettingsSchemaSource { 57 | let default = gio::SettingsSchemaSource::default().unwrap(); 58 | if cfg!(debug_assertions) { 59 | let directory = concat!(env!("CARGO_MANIFEST_DIR"), "/schemas"); 60 | if std::fs::exists(directory).unwrap_or_default() { 61 | gio::SettingsSchemaSource::from_directory(directory, Some(&default), false).unwrap() 62 | } else { 63 | default 64 | } 65 | } else { 66 | default 67 | } 68 | } 69 | 70 | /// Get the locale directory. 71 | /// 72 | /// Return the flatpak locale directory when in 73 | pub fn locale_directory() -> &'static GStr { 74 | if running_in_flatpak() { 75 | gstr!("/app/share/locale") 76 | } else { 77 | gstr!("/usr/share/locale") 78 | } 79 | } 80 | 81 | #[cfg(test)] 82 | mod tests { 83 | 84 | #[test] 85 | fn release_notes_version_only_has_major_and_minor() { 86 | let version = super::release_notes_version(); 87 | assert_eq!(version.major, super::cargo_pkg_version().major); 88 | assert_eq!(version.minor, super::cargo_pkg_version().minor); 89 | assert_eq!(version.patch, 0); 90 | assert!(version.pre.is_empty()); 91 | assert!(version.build.is_empty()); 92 | } 93 | #[test] 94 | fn release_notes_for_release_notes_version() { 95 | let metadata = std::fs::read_to_string(concat!( 96 | env!("CARGO_MANIFEST_DIR"), 97 | "/resources/de.swsnr.turnon.metainfo.xml.in" 98 | )) 99 | .unwrap(); 100 | assert!(metadata.contains(&format!( 101 | " 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! D-Bus interface definitions and helpers. 8 | 9 | pub mod searchprovider2; 10 | -------------------------------------------------------------------------------- /src/dbus/searchprovider2.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! The search provider dbus interface. 8 | 9 | use glib::Variant; 10 | use gtk::{ 11 | gio::{DBusError, DBusInterfaceInfo, DBusNodeInfo}, 12 | prelude::DBusMethodCall, 13 | }; 14 | 15 | /// The literal XML definition of the interface. 16 | const XML: &str = include_str!("../../dbus-1/org.gnome.ShellSearchProvider2.xml"); 17 | 18 | /// The name of the interface. 19 | pub const INTERFACE_NAME: &str = "org.gnome.Shell.SearchProvider2"; 20 | 21 | /// Get the D-Bus interface info for the search provider interface. 22 | pub fn interface() -> DBusInterfaceInfo { 23 | // We unwrap here since we know that the XML is valid and contains the 24 | // desired interface, so none of this can realistically fail. 25 | DBusNodeInfo::for_xml(XML) 26 | .unwrap() 27 | .lookup_interface(INTERFACE_NAME) 28 | .unwrap() 29 | } 30 | 31 | #[derive(Debug, Variant)] 32 | pub struct GetInitialResultSet { 33 | pub terms: Vec, 34 | } 35 | 36 | #[derive(Debug, Variant)] 37 | pub struct GetSubsearchResultSet { 38 | pub previous_results: Vec, 39 | pub terms: Vec, 40 | } 41 | 42 | #[derive(Debug, Variant)] 43 | pub struct GetResultMetas { 44 | pub identifiers: Vec, 45 | } 46 | 47 | #[derive(Debug, Variant)] 48 | pub struct ActivateResult { 49 | pub identifier: String, 50 | pub terms: Vec, 51 | pub timestamp: u32, 52 | } 53 | 54 | #[derive(Debug, Variant)] 55 | pub struct LaunchSearch { 56 | pub terms: Vec, 57 | pub timestamp: u32, 58 | } 59 | 60 | /// Method calls a search provider supports. 61 | #[derive(Debug)] 62 | pub enum MethodCall { 63 | GetInitialResultSet(GetInitialResultSet), 64 | GetSubsearchResultSet(GetSubsearchResultSet), 65 | GetResultMetas(GetResultMetas), 66 | ActivateResult(ActivateResult), 67 | LaunchSearch(LaunchSearch), 68 | } 69 | 70 | fn invalid_parameters() -> glib::Error { 71 | glib::Error::new(DBusError::InvalidArgs, "Invalid parameters for method") 72 | } 73 | 74 | impl DBusMethodCall for MethodCall { 75 | fn parse_call( 76 | _obj_path: &str, 77 | interface: Option<&str>, 78 | method: &str, 79 | params: glib::Variant, 80 | ) -> Result { 81 | if interface != Some(INTERFACE_NAME) { 82 | return Err(glib::Error::new( 83 | DBusError::UnknownInterface, 84 | "Unexpected interface", 85 | )); 86 | } 87 | match method { 88 | "GetInitialResultSet" => params 89 | .get::() 90 | .map(MethodCall::GetInitialResultSet) 91 | .ok_or_else(invalid_parameters), 92 | "GetSubsearchResultSet" => params 93 | .get::() 94 | .map(MethodCall::GetSubsearchResultSet) 95 | .ok_or_else(invalid_parameters), 96 | "GetResultMetas" => params 97 | .get::() 98 | .map(MethodCall::GetResultMetas) 99 | .ok_or_else(invalid_parameters), 100 | "ActivateResult" => params 101 | .get::() 102 | .map(MethodCall::ActivateResult) 103 | .ok_or_else(invalid_parameters), 104 | "LaunchSearch" => params 105 | .get::() 106 | .map(MethodCall::LaunchSearch) 107 | .ok_or_else(invalid_parameters), 108 | _ => Err(glib::Error::new( 109 | DBusError::UnknownMethod, 110 | "Unexpected method", 111 | )), 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/futures.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::future::Future; 8 | 9 | use gtk::gio::IOErrorEnum; 10 | 11 | /// Like [`glib::future_with_timeout`] but flattens errors of fallible futures. 12 | pub async fn future_with_timeout( 13 | timeout: std::time::Duration, 14 | fut: impl Future>, 15 | ) -> Result { 16 | glib::future_with_timeout(timeout, fut) 17 | .await 18 | .map_err(|_| { 19 | glib::Error::new( 20 | IOErrorEnum::TimedOut, 21 | &format!("Timeout after {}ms", timeout.as_millis()), 22 | ) 23 | }) 24 | .and_then(|inner| inner) 25 | } 26 | -------------------------------------------------------------------------------- /src/gettext.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::ffi::c_int; 8 | use std::io::Result; 9 | 10 | use glib::{GStr, gstr}; 11 | 12 | fn bindtextdomain(domainname: &GStr, locale_dir: &GStr) -> Result<()> { 13 | // SAFETY: domainname and locale_dir, being GStrs, are nul-terminated. 14 | // bindtextdomain does not take ownership of these pointers so we need not copy. 15 | // We ignore the returned pointer, other than checking for NULL. 16 | let new_dir = unsafe { native::bindtextdomain(domainname.as_ptr(), locale_dir.as_ptr()) }; 17 | if new_dir.is_null() { 18 | Err(std::io::Error::last_os_error()) 19 | } else { 20 | Ok(()) 21 | } 22 | } 23 | 24 | fn textdomain(domainname: &GStr) -> Result<()> { 25 | // SAFETY: domainname, being a GStr, is nul-terminated. textdomain does not take ownership of this pointer so we 26 | // need not copy. We ignore the returned pointer, other than checking for NULL. 27 | let new_domain = unsafe { native::textdomain(domainname.as_ptr()) }; 28 | if new_domain.is_null() { 29 | Err(std::io::Error::last_os_error()) 30 | } else { 31 | Ok(()) 32 | } 33 | } 34 | 35 | fn bind_textdomain_codeset(domainname: &GStr, codeset: &GStr) -> Result<()> { 36 | // SAFETY: domainname and codeset, being GStrs, are nul-terminated already. bind_textdomain_codeset does not take 37 | // ownership of these pointers so we need not copy. We ignore the returned pointer, other than checking for NULL. 38 | let new_codeset = 39 | unsafe { native::bind_textdomain_codeset(domainname.as_ptr(), codeset.as_ptr()) }; 40 | if new_codeset.is_null() { 41 | Err(std::io::Error::last_os_error()) 42 | } else { 43 | Ok(()) 44 | } 45 | } 46 | 47 | fn setlocale(category: c_int, locale: &GStr) { 48 | // SAFETY: locale, being a GStr, is nul-terminated already. setlocale does not take ownership of this pointer so 49 | // we need not copy. We just ignore the return value as we don't need the old locale value and there's nothing we 50 | // can do about errors anyway. 51 | unsafe { libc::setlocale(category, locale.as_ptr()) }; 52 | } 53 | 54 | /// Initialize gettext. 55 | /// 56 | /// Set locale and text domain, and bind the text domain to the given `locale_dir`. 57 | /// 58 | /// See . 59 | pub fn init_gettext(text_domain: &GStr, locale_dir: &GStr) -> Result<()> { 60 | setlocale(libc::LC_ALL, gstr!("")); 61 | bindtextdomain(text_domain, locale_dir)?; 62 | bind_textdomain_codeset(text_domain, gstr!("UTF-8"))?; 63 | textdomain(text_domain)?; 64 | Ok(()) 65 | } 66 | 67 | mod native { 68 | use std::ffi::c_char; 69 | 70 | unsafe extern "C" { 71 | pub fn bindtextdomain(domainname: *const c_char, dirname: *const c_char) -> *mut c_char; 72 | 73 | pub fn textdomain(domain_name: *const c_char) -> *mut c_char; 74 | 75 | pub fn bind_textdomain_codeset( 76 | domainname: *const c_char, 77 | codeset: *const c_char, 78 | ) -> *mut c_char; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | #![deny(warnings, clippy::all, clippy::pedantic, 8 | // Do cfg(test) right 9 | clippy::cfg_not_test, 10 | clippy::tests_outside_test_module, 11 | // Guard against left-over debugging output 12 | clippy::dbg_macro, 13 | clippy::print_stderr, 14 | clippy::print_stdout, 15 | clippy::unimplemented, 16 | clippy::use_debug, 17 | clippy::todo, 18 | // Require correct safety docs 19 | clippy::undocumented_unsafe_blocks, 20 | clippy::unnecessary_safety_comment, 21 | clippy::unnecessary_safety_doc, 22 | // We must use Gtk's APIs to exit the app. 23 | clippy::exit, 24 | // Don't panic carelessly 25 | clippy::get_unwrap, 26 | clippy::unused_result_ok, 27 | clippy::unwrap_in_result, 28 | clippy::indexing_slicing, 29 | // Do not carelessly ignore errors 30 | clippy::let_underscore_must_use, 31 | clippy::let_underscore_untyped, 32 | // Code smells 33 | clippy::float_cmp_const, 34 | clippy::string_to_string, 35 | clippy::if_then_some_else_none, 36 | clippy::large_include_file, 37 | // Disable as casts 38 | clippy::as_conversions, 39 | )] 40 | #![allow(clippy::enum_glob_use, clippy::module_name_repetitions)] 41 | 42 | use adw::prelude::*; 43 | use app::TurnOnApplication; 44 | use gtk::gio; 45 | use gtk::glib; 46 | 47 | mod app; 48 | mod config; 49 | mod dbus; 50 | mod futures; 51 | mod gettext; 52 | mod net; 53 | 54 | use config::G_LOG_DOMAIN; 55 | 56 | fn main() -> glib::ExitCode { 57 | static GLIB_LOGGER: glib::GlibLogger = glib::GlibLogger::new( 58 | glib::GlibLoggerFormat::Structured, 59 | glib::GlibLoggerDomain::CrateTarget, 60 | ); 61 | let max_level = if std::env::var_os("G_MESSAGES_DEBUG").is_some() { 62 | log::LevelFilter::Trace 63 | } else { 64 | log::LevelFilter::Warn 65 | }; 66 | log::set_max_level(max_level); 67 | log::set_logger(&GLIB_LOGGER).unwrap(); 68 | 69 | let locale_dir = config::locale_directory(); 70 | glib::debug!("Initializing gettext with locale directory {}", locale_dir); 71 | if let Err(error) = gettext::init_gettext(config::APP_ID, locale_dir) { 72 | glib::warn!("Failed to initialize gettext: {error}"); 73 | } 74 | 75 | gio::resources_register_include!("turnon.gresource").unwrap(); 76 | glib::set_application_name("Turn On"); 77 | 78 | let app = TurnOnApplication::default(); 79 | app.set_version(config::CARGO_PKG_VERSION); 80 | app.run() 81 | } 82 | -------------------------------------------------------------------------------- /src/net.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Networking for Turn On. 8 | //! 9 | //! This module provides various utilities around networking required by Turn On. 10 | //! Specifically, it has a user-space ping implementation, a Wake-On-Lan 11 | //! implementation, some helper types, and various tools for network scanning. 12 | 13 | pub mod arpcache; 14 | mod http; 15 | mod macaddr; 16 | mod monitor; 17 | mod ping; 18 | mod wol; 19 | 20 | pub use http::probe_http; 21 | pub use macaddr::MacAddr6Boxed; 22 | pub use monitor::monitor; 23 | pub use ping::{PingDestination, ping_address}; 24 | pub use wol::wol; 25 | -------------------------------------------------------------------------------- /src/net/arpcache.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Access the Linux ARP cache. 8 | 9 | use std::fmt::Display; 10 | use std::io::BufReader; 11 | use std::io::ErrorKind; 12 | use std::io::prelude::*; 13 | use std::net::{AddrParseError, Ipv4Addr}; 14 | use std::num::ParseIntError; 15 | use std::path::Path; 16 | use std::str::FromStr; 17 | 18 | use bitflags::bitflags; 19 | use macaddr::MacAddr6; 20 | 21 | /// A ARP hardware type. 22 | /// 23 | /// See 24 | /// for known hardware types as of Linux 6.12. 25 | /// 26 | /// We do not represent all hardware types, but only those we're interested in 27 | /// with regards to Turn On. 28 | #[derive(Debug, PartialEq, Eq)] 29 | #[repr(u16)] 30 | pub enum ArpKnownHardwareType { 31 | // Ethernet (including WiFi) 32 | Ether = 1, 33 | } 34 | 35 | /// A known or unknown hardware type. 36 | #[derive(Debug, PartialEq, Eq)] 37 | pub enum ArpHardwareType { 38 | /// A hardware type we know. 39 | Known(ArpKnownHardwareType), 40 | /// A hardware type we do not understand. 41 | Unknown(u16), 42 | } 43 | 44 | impl FromStr for ArpHardwareType { 45 | type Err = ParseIntError; 46 | 47 | fn from_str(s: &str) -> Result { 48 | use ArpHardwareType::*; 49 | let hw_type = match u16::from_str_radix(s, 16)? { 50 | 1 => Known(ArpKnownHardwareType::Ether), 51 | other => Unknown(other), 52 | }; 53 | Ok(hw_type) 54 | } 55 | } 56 | 57 | bitflags! { 58 | /// Flags for ARP cache entries. 59 | /// 60 | /// See 61 | /// for known flags as of Linux 6.12. 62 | #[derive(Debug, Eq, PartialEq)] 63 | pub struct ArpCacheEntryFlags: u8 { 64 | /// completed entry (ha valid) 65 | const ATF_COM = 0x02; 66 | /// permanent entry 67 | const ATF_PERM = 0x04; 68 | /// publish entry 69 | const ATF_PUBL = 0x08; 70 | /// has requested trailers 71 | const ATF_USETRAILERS = 0x10; 72 | /// want to use a netmask (only for proxy entries) 73 | const ATF_NETMASK = 0x20; 74 | /// don't answer this addresses 75 | const ATF_DONTPUB = 0x40; 76 | } 77 | } 78 | 79 | impl FromStr for ArpCacheEntryFlags { 80 | type Err = ParseIntError; 81 | 82 | /// Parse flags, discarding unknown flags. 83 | fn from_str(s: &str) -> Result { 84 | Ok(ArpCacheEntryFlags::from_bits_truncate(u8::from_str(s)?)) 85 | } 86 | } 87 | 88 | /// An ARP cache entry. 89 | #[derive(Debug)] 90 | pub struct ArpCacheEntry { 91 | /// The IP address. 92 | pub ip_address: Ipv4Addr, 93 | /// The hardware type. 94 | pub hardware_type: ArpHardwareType, 95 | /// Internal flags for this cache entry. 96 | pub flags: ArpCacheEntryFlags, 97 | /// The hardware address for this entry. 98 | pub hardware_address: MacAddr6, 99 | } 100 | 101 | #[derive(Debug)] 102 | pub enum ArpCacheParseError { 103 | MissingCell(&'static str, u8), 104 | InvalidIpAddress(AddrParseError), 105 | InvalidHardwareType(ParseIntError), 106 | InvalidFlags(ParseIntError), 107 | InvalidHardwareAddess(macaddr::ParseError), 108 | } 109 | 110 | impl Display for ArpCacheParseError { 111 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 112 | match self { 113 | ArpCacheParseError::MissingCell(cell, index) => { 114 | write!(f, "Missing cell {cell} at index {index}") 115 | } 116 | ArpCacheParseError::InvalidIpAddress(addr_parse_error) => { 117 | write!(f, "Failed to parse IP address: {addr_parse_error}") 118 | } 119 | ArpCacheParseError::InvalidHardwareType(parse_int_error) => { 120 | write!(f, "Invalid hardware type: {parse_int_error}") 121 | } 122 | ArpCacheParseError::InvalidFlags(parse_int_error) => { 123 | write!(f, "Invalid flags: {parse_int_error}") 124 | } 125 | ArpCacheParseError::InvalidHardwareAddess(parse_error) => { 126 | write!(f, "Failed to parse hardware address: {parse_error}") 127 | } 128 | } 129 | } 130 | } 131 | 132 | impl std::error::Error for ArpCacheParseError { 133 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 134 | match self { 135 | ArpCacheParseError::InvalidIpAddress(addr_parse_error) => Some(addr_parse_error), 136 | ArpCacheParseError::InvalidHardwareType(parse_int_error) 137 | | ArpCacheParseError::InvalidFlags(parse_int_error) => Some(parse_int_error), 138 | _ => None, 139 | } 140 | } 141 | } 142 | 143 | impl From for ArpCacheParseError { 144 | fn from(value: AddrParseError) -> Self { 145 | ArpCacheParseError::InvalidIpAddress(value) 146 | } 147 | } 148 | 149 | impl From for ArpCacheParseError { 150 | fn from(value: macaddr::ParseError) -> Self { 151 | ArpCacheParseError::InvalidHardwareAddess(value) 152 | } 153 | } 154 | 155 | impl FromStr for ArpCacheEntry { 156 | type Err = ArpCacheParseError; 157 | 158 | /// Parse an ARP cache entry from one line of `/proc/net/arp`. 159 | /// 160 | /// See `proc_net(5)` for some details. 161 | fn from_str(s: &str) -> Result { 162 | use ArpCacheParseError::*; 163 | let mut parts = s.trim_ascii().split_ascii_whitespace(); 164 | let ip_address = Ipv4Addr::from_str(parts.next().ok_or(MissingCell("IP address", 0))?)?; 165 | let hardware_type = ArpHardwareType::from_str( 166 | parts 167 | .next() 168 | .ok_or(MissingCell("HW type", 1))? 169 | .trim_start_matches("0x"), 170 | ) 171 | .map_err(InvalidHardwareType)?; 172 | let flags = ArpCacheEntryFlags::from_str( 173 | parts 174 | .next() 175 | .ok_or(MissingCell("Flags", 2))? 176 | .trim_start_matches("0x"), 177 | ) 178 | .map_err(InvalidFlags)?; 179 | let hardware_address = 180 | MacAddr6::from_str(parts.next().ok_or(MissingCell("HW address", 3))?)?; 181 | // The cache table also has mask and device columns, but we don't care for these 182 | Ok(ArpCacheEntry { 183 | ip_address, 184 | hardware_type, 185 | flags, 186 | hardware_address, 187 | }) 188 | } 189 | } 190 | 191 | /// Read the ARP cache table from the given reader. 192 | /// 193 | /// The reader is expected to provide a textual ARP cache table, as in `/proc/net/arp`. 194 | /// 195 | /// Return an iterator over all valid cache entries. 196 | pub fn read_arp_cache( 197 | reader: R, 198 | ) -> impl Iterator> { 199 | reader 200 | .lines() 201 | .skip(1) // skip over the headling line 202 | .map(|l| { 203 | l.and_then(|l| { 204 | ArpCacheEntry::from_str(&l) 205 | .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e)) 206 | }) 207 | }) 208 | } 209 | 210 | /// Read the ARP cache table from the given path. 211 | /// 212 | /// The file is expected to contain a textual ARP cache table, as in `/proc/net/arp`. 213 | /// 214 | /// Return an iterator over all valid cache entries. 215 | pub fn read_arp_cache_from_path>( 216 | path: P, 217 | ) -> std::io::Result>> { 218 | let source = BufReader::new(std::fs::File::open(path)?); 219 | Ok(read_arp_cache(source)) 220 | } 221 | 222 | /// Get the default path to the ARP cache table. 223 | pub fn default_arp_cache_path() -> &'static Path { 224 | Path::new("/proc/net/arp") 225 | } 226 | 227 | #[cfg(test)] 228 | mod tests { 229 | use std::{net::Ipv4Addr, str::FromStr}; 230 | 231 | use macaddr::MacAddr6; 232 | 233 | use super::*; 234 | 235 | #[test] 236 | pub fn test_arp_cache_entry_from_str() { 237 | let entry = ArpCacheEntry::from_str( 238 | "192.168.178.130 0x1 0x2 b6:a3:b0:48:80:f1 * wlp4s0 239 | ", 240 | ) 241 | .unwrap(); 242 | assert_eq!(entry.ip_address, Ipv4Addr::new(192, 168, 178, 130)); 243 | assert_eq!( 244 | entry.hardware_type, 245 | ArpHardwareType::Known(ArpKnownHardwareType::Ether) 246 | ); 247 | assert_eq!(entry.flags, ArpCacheEntryFlags::ATF_COM); 248 | assert_eq!( 249 | entry.hardware_address, 250 | MacAddr6::new(0xb6, 0xa3, 0xb0, 0x48, 0x80, 0xf1) 251 | ); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/net/http.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | use std::fmt::Display; 8 | use std::net::IpAddr; 9 | use std::str::FromStr; 10 | use std::time::Duration; 11 | 12 | use gtk::gio::prelude::SocketClientExt; 13 | use gtk::gio::{IOErrorEnum, SocketClient}; 14 | 15 | use crate::config::G_LOG_DOMAIN; 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 18 | pub enum HttpScheme { 19 | Http, 20 | Https, 21 | } 22 | 23 | impl HttpScheme { 24 | pub fn as_str(self) -> &'static str { 25 | match self { 26 | HttpScheme::Http => "http:", 27 | HttpScheme::Https => "https:", 28 | } 29 | } 30 | 31 | pub fn port(self) -> u16 { 32 | match self { 33 | HttpScheme::Http => 80, 34 | HttpScheme::Https => 443, 35 | } 36 | } 37 | 38 | pub fn to_url(self, host: &str) -> String { 39 | if IpAddr::from_str(host).is_ok_and(|ip| ip.is_ipv6()) { 40 | format!("{self}//[{host}]") 41 | } else { 42 | format!("{self}//{host}") 43 | } 44 | } 45 | } 46 | 47 | impl Display for HttpScheme { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | write!(f, "{}", self.as_str()) 50 | } 51 | } 52 | 53 | /// Probe a host for HTTP support. 54 | /// 55 | /// Attempt to connect to `host` via standard HTTPS and HTTP ports 443 and 80 56 | /// respectively, and return the supported scheme if either succeeds. 57 | pub async fn probe_http(host: &str, timeout: Duration) -> Option { 58 | for scheme in [HttpScheme::Https, HttpScheme::Http] { 59 | let client = SocketClient::new(); 60 | client.set_tls(matches!(scheme, HttpScheme::Https)); 61 | // We probe hosts we can ping directly, so there should never be a proxy involved. 62 | client.set_enable_proxy(false); 63 | client.set_timeout(u32::try_from(timeout.as_secs()).unwrap()); 64 | let result = 65 | glib::future_with_timeout(timeout, client.connect_to_host_future(host, scheme.port())) 66 | .await 67 | .map_err(|_| { 68 | glib::Error::new(IOErrorEnum::TimedOut, &format!("Host {host} timed out")) 69 | }) 70 | .and_then(|r| r) 71 | .inspect_err(|error| { 72 | glib::info!("{host} not reachable via {scheme}: {error}"); 73 | }); 74 | if result.is_ok() { 75 | glib::info!("{host} reachable via {scheme}"); 76 | return Some(scheme); 77 | } 78 | } 79 | None 80 | } 81 | -------------------------------------------------------------------------------- /src/net/macaddr.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Simple MAC address type on top of [`glib::Bytes`]. 8 | //! 9 | //! While this is not the most efficient approach it allows storing the MAC 10 | //! address as a glib property. 11 | 12 | use std::fmt::Display; 13 | use std::ops::Deref; 14 | use std::str::FromStr; 15 | 16 | use macaddr::MacAddr6; 17 | 18 | /// Boxed [`MacAddr6`]. 19 | /// 20 | /// Define a MAC address type for glib, by boxing a [`MacAdd6`]. 21 | #[derive(Default, Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord, glib::Boxed)] 22 | #[boxed_type(name = "MacAdd6")] 23 | pub struct MacAddr6Boxed(MacAddr6); 24 | 25 | impl From for MacAddr6Boxed { 26 | fn from(value: MacAddr6) -> Self { 27 | Self(value) 28 | } 29 | } 30 | 31 | impl FromStr for MacAddr6Boxed { 32 | type Err = macaddr::ParseError; 33 | 34 | fn from_str(s: &str) -> Result { 35 | MacAddr6::from_str(s).map(Into::into) 36 | } 37 | } 38 | 39 | impl Deref for MacAddr6Boxed { 40 | type Target = MacAddr6; 41 | 42 | fn deref(&self) -> &Self::Target { 43 | &self.0 44 | } 45 | } 46 | 47 | impl Display for MacAddr6Boxed { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | self.0.fmt(f) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/net/monitor.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Monitor network destinations with periodic pings. 8 | 9 | use std::net::IpAddr; 10 | use std::rc::Rc; 11 | use std::{cell::RefCell, time::Duration}; 12 | 13 | use futures_util::{Stream, StreamExt}; 14 | 15 | use crate::config::G_LOG_DOMAIN; 16 | use crate::futures::future_with_timeout; 17 | 18 | use super::{PingDestination, ping_address}; 19 | 20 | /// Monitor a network destination. 21 | /// 22 | /// Periodically ping`destination` at the given `interval` and yield the results. 23 | /// 24 | /// Return a stream which yields `Ok` if the destination could be resolved and 25 | /// replied to echo requests, or `Err` if a ping failed. In the former case, 26 | /// return the resolved IP address and the roundtrip duration for the ping. 27 | pub fn monitor( 28 | destination: PingDestination, 29 | interval: Duration, 30 | ) -> impl Stream> { 31 | let cached_ip_address: Rc>> = Rc::default(); 32 | let timeout = interval / 2; 33 | futures_util::stream::iter(vec![()]) 34 | .chain(glib::interval_stream(interval)) 35 | .enumerate() 36 | .map(|(seqnr, ())| u16::try_from(seqnr % usize::from(u16::MAX)).unwrap()) 37 | .scan(cached_ip_address, move |state, seqnr| { 38 | let destination = destination.clone(); 39 | let state = state.clone(); 40 | async move { 41 | // Take any cached IP address out of the state, leaving an empty state. 42 | // If we get a reply from the IP address we'll cache it again after pinging it. 43 | let result = match state.take() { 44 | // If we have a cached IP address, ping it, and cache it again 45 | // if it's still reachable. 46 | Some(address) => future_with_timeout(timeout, ping_address(address, seqnr)) 47 | .await 48 | .inspect(|duration| { 49 | glib::trace!( 50 | "Cached address {address} replied to ping after \ 51 | {}ms and is still reachable, caching again", 52 | duration.as_millis() 53 | ); 54 | state.replace(Some(address)); 55 | }) 56 | .map(|duration| (address, duration)), 57 | // If we have no cached IP address resolve the destination and ping all 58 | // addresses it resolves to, then cache the first reachable address. 59 | None => future_with_timeout(timeout, destination.ping(seqnr)) 60 | .await 61 | .inspect(|(address, duration)| { 62 | glib::trace!( 63 | "{address} of {destination} replied after {}ms, caching", 64 | duration.as_millis() 65 | ); 66 | state.replace(Some(*address)); 67 | }), 68 | }; 69 | Some(result) 70 | } 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /src/net/wol.rs: -------------------------------------------------------------------------------- 1 | // Copyright Sebastian Wiesner 2 | 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | //! Wake On LAN (magic packet) implementation. 8 | 9 | use std::net::{Ipv4Addr, SocketAddr}; 10 | 11 | use glib::IOCondition; 12 | use gtk::gio::Cancellable; 13 | use gtk::gio::prelude::{SocketExt, SocketExtManual}; 14 | use gtk::gio::{self, IOErrorEnum}; 15 | use macaddr::MacAddr6; 16 | 17 | /// Send a magic Wake On LAN packet to the given `mac_address`. 18 | /// 19 | /// Sends the magic package as UDP package to port 9 on the IPv4 broadcast address. 20 | pub async fn wol(mac_address: MacAddr6) -> Result<(), glib::Error> { 21 | let socket = gio::Socket::new( 22 | gio::SocketFamily::Ipv4, 23 | gio::SocketType::Datagram, 24 | gio::SocketProtocol::Udp, 25 | )?; 26 | socket.set_broadcast(true); 27 | 28 | let condition = socket 29 | .create_source_future(IOCondition::OUT, Cancellable::NONE, glib::Priority::DEFAULT) 30 | .await; 31 | if condition != glib::IOCondition::OUT { 32 | return Err(glib::Error::new( 33 | IOErrorEnum::BrokenPipe, 34 | &format!("Socket for waking {mac_address} not ready to write"), 35 | )); 36 | } 37 | let mut payload = [0; 102]; 38 | wol::fill_magic_packet(&mut payload, mac_address); 39 | let broadcast_and_discard_address: gio::InetSocketAddress = 40 | SocketAddr::new(Ipv4Addr::BROADCAST.into(), 9).into(); 41 | let bytes_sent = socket.send_to( 42 | Some(&broadcast_and_discard_address), 43 | payload, 44 | Cancellable::NONE, 45 | )?; 46 | assert!(bytes_sent == payload.len()); 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /supply-chain/config.toml: -------------------------------------------------------------------------------- 1 | 2 | # cargo-vet config file 3 | 4 | [cargo-vet] 5 | version = "0.10" 6 | 7 | [imports.bytecode-alliance] 8 | url = "https://raw.githubusercontent.com/bytecodealliance/wasmtime/main/supply-chain/audits.toml" 9 | 10 | [imports.embark-studios] 11 | url = "https://raw.githubusercontent.com/EmbarkStudios/rust-ecosystem/main/audits.toml" 12 | 13 | [imports.google] 14 | url = "https://raw.githubusercontent.com/google/supply-chain/main/audits.toml" 15 | 16 | [imports.mozilla] 17 | url = "https://raw.githubusercontent.com/mozilla/supply-chain/main/audits.toml" 18 | 19 | [imports.swsnr] 20 | url = "https://raw.githubusercontent.com/swsnr/rust-supply-chain/refs/heads/main/audits.toml" 21 | 22 | [policy.turnon] 23 | criteria = "safe-to-run" 24 | 25 | [[exemptions.concurrent-queue]] 26 | version = "2.5.0" 27 | criteria = "safe-to-run" 28 | 29 | [[exemptions.event-listener]] 30 | version = "5.3.1" 31 | criteria = "safe-to-run" 32 | 33 | [[exemptions.event-listener-strategy]] 34 | version = "0.5.2" 35 | criteria = "safe-to-run" 36 | --------------------------------------------------------------------------------