From 8db26bdd663740b519380a56a4a5697bdcf4dcf4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 28 Oct 2026 13:06:04 +0000 Subject: [PATCH 01/15] feat: add offline trial and desktop licence recovery --- .github/workflows/release-tauri.yml | 2 + docs/licensing.md | 30 ++ site/src/content/docs/index.md | 2 +- site/src/content/docs/licence.md | 28 +- src-tauri/Cargo.lock | 301 ++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 4 + src-tauri/capabilities/main.json | 4 + .../autogenerated/paste_licence.toml | 11 + .../autogenerated/redeem_licence.toml | 11 + .../autogenerated/remove_licence.toml | 11 + .../autogenerated/set_licence_recovery.toml | 11 + src-tauri/src/lib.rs | 13 + src-tauri/src/licensing.rs | 499 ++++++++++++++++++ src-tauri/src/macos.rs | 3 + src-tauri/src/protocol.rs | 52 ++ src-tauri/src/scanning_runtime.rs | 18 + src-tauri/src/state.rs | 2 + src-tauri/src/storage.rs | 4 + src-tauri/src/windows_runtime.rs | 14 +- src/App.tsx | 1 + src/settings/AboutSection.tsx | 2 +- src/settings/LicenceSection.test.tsx | 33 ++ src/settings/LicenceSection.tsx | 82 +++ src/settings/SettingsView.tsx | 10 +- src/settings/settings.test.tsx | 6 +- src/types.ts | 3 + 27 files changed, 1140 insertions(+), 18 deletions(-) create mode 100644 docs/licensing.md create mode 100644 src-tauri/permissions/autogenerated/paste_licence.toml create mode 100644 src-tauri/permissions/autogenerated/redeem_licence.toml create mode 100644 src-tauri/permissions/autogenerated/remove_licence.toml create mode 100644 src-tauri/permissions/autogenerated/set_licence_recovery.toml create mode 100644 src-tauri/src/licensing.rs create mode 100644 src/settings/LicenceSection.test.tsx create mode 100644 src/settings/LicenceSection.tsx diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index adbe8494..df057ccf 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -142,6 +142,7 @@ jobs: APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + SWITCHIFY_LICENCE_PUBLIC_KEYS: ${{ vars.SWITCHIFY_LICENCE_PUBLIC_KEYS }} SWITCHIFY_SUPABASE_URL: ${{ vars.SWITCHIFY_SUPABASE_URL }} SWITCHIFY_SUPABASE_PUBLISHABLE_KEY: ${{ vars.SWITCHIFY_SUPABASE_PUBLISHABLE_KEY }} steps: @@ -338,6 +339,7 @@ jobs: SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + SWITCHIFY_LICENCE_PUBLIC_KEYS: ${{ vars.SWITCHIFY_LICENCE_PUBLIC_KEYS }} SWITCHIFY_SUPABASE_URL: ${{ vars.SWITCHIFY_SUPABASE_URL }} SWITCHIFY_SUPABASE_PUBLISHABLE_KEY: ${{ vars.SWITCHIFY_SUPABASE_PUBLISHABLE_KEY }} steps: diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 00000000..8f177169 --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,30 @@ +# Desktop licensing operations + +Depends on the version-1 format in `tools/licence-keygen/README.md`. + +Set repository variable `SWITCHIFY_LICENCE_PUBLIC_KEYS` before the first paid +release. Its value is a JSON object mapping issuer IDs to raw 32-byte Ed25519 +public keys in unpadded base64url. Never put the private key in this variable. +Release jobs pass this public configuration into the compiled Rust verifier. +No variable means licensing is disabled (development/CI and existing releases); +a nonempty invalid configuration locks paid features and reports an error. +Do not publish a paid build without testing its actual public configuration. + +Store the matching signing key outside Git and separately in the fulfilment +service's secret store. Generate a partner key and a sandbox purchase key, +then verify both in the actual paid build while disconnected from the network. +Do not embed the test-vector issuer into production. + +Local `licence.json` and the separate `com.enaboapps.switchify.pc.licence` +keychain record retain the earliest trial start and latest observed timestamp. +A revision counter ensures a removed key is not restored from an older mirror. +Licence state does not enter account settings, pairing records or cloud sync. +The trial starts on the first run of a licensing-enabled build, including +upgrades from older free versions. Offline trial reset prevention is best effort. +A local clock rollback does not rewind the recorded trial timestamp. + +Before going live, verify on macOS and Windows: activation, restart offline, +removal, invalid-key replacement, file import, temporary keychain unavailability, +expiry during held input/repeats, and local-switch recovery with focus moving +between applications. Run macOS native checks with `npm run macos:run`. +Automated tests must use fake input and must not read the real clipboard or keychain. diff --git a/site/src/content/docs/index.md b/site/src/content/docs/index.md index 9422c199..323a18df 100644 --- a/site/src/content/docs/index.md +++ b/site/src/content/docs/index.md @@ -5,7 +5,7 @@ description: Switch access for Windows and Mac. Point anywhere on screen, open m Switchify PC lets you use a Windows PC or a Mac with one or more switches. It runs on the computer itself. You can also pair it with Switchify on an Android phone or tablet to control the computer from there. -It is free and open source under the AGPL-3.0. +It is open source under the AGPL-3.0. See [licences, trial and purchase keys](/licence/). ## What you can do with a switch diff --git a/site/src/content/docs/licence.md b/site/src/content/docs/licence.md index 63bd2c85..8df90397 100644 --- a/site/src/content/docs/licence.md +++ b/site/src/content/docs/licence.md @@ -1,13 +1,35 @@ --- title: Licence -description: Switchify PC is free and open source under the GNU Affero General Public License v3, and the parts it includes from other projects keep their own licences. +description: Switchify PC is open source under the GNU Affero General Public License v3, and the parts it includes from other projects keep their own licences. --- ## Switchify PC -Switchify PC is free and open source software. It is licensed under the [GNU Affero General Public License, version 3](https://github.com/switchifyapp/switchify-pc/blob/main/LICENSE) (AGPL v3). +Switchify PC is open source software. It is licensed under the [GNU Affero General Public License, version 3](https://github.com/switchifyapp/switchify-pc/blob/main/LICENSE) (AGPL v3). -You can use it for free, at home, at school, at work or in a care setting. There is no agreement to accept before you install it. +## Purchase keys and the trial + +Builds with licensing enabled provide a full 30-day trial on this computer. A +Switchify PC licence costs €100 once for one computer. When the trial ends, +computer scanning, dwell clicking and mobile control stop. Settings, account +management and licence entry remain available. Ordinary development builds do +not enable paid licensing. + +Open **Settings → Licence** to paste a key, choose **Paste and activate**, or +import a text licence file. The key is checked on your computer and needs no +internet connection or Switchify account. Keep a private copy for reinstalling +or moving to another computer; stop using the old computer when transferring +its licence. Removing a key does not refund a purchase or restart the trial. +Signing out or deleting your Switchify account does not remove the local key. + +If the trial has ended, the Licence page automatically moves focus between +buttons every 1.5 seconds. Any configured local switch selects the focused +button. Copy your key first, then choose **Paste and activate**. This recovery +mode controls only this page; it does not type or move the pointer in other +applications. For help or a lost key, contact info@theswitchify.app. + +Purchases and automatic key delivery are being prepared. Do not use an +unannounced checkout link until licence delivery is available. The licence also lets you study, change and share the app. If you share it, changed or not, the licence sets out how you must make the source code available. A changed version you share must stay under the same licence. If people use your changed version over a network, you must offer them its source code. The [source code](https://github.com/switchifyapp/switchify-pc) is on GitHub. Read the licence for exactly what applies. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9ab5569f..24a82bcc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -137,6 +137,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -713,6 +734,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -886,6 +916,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1202,7 +1238,7 @@ checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", @@ -1219,6 +1255,12 @@ dependencies = [ "futures-util", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1372,6 +1414,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.2" @@ -1410,6 +1458,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1451,6 +1505,12 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1468,6 +1528,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1906,19 +1972,39 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1927,7 +2013,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2254,6 +2340,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", ] [[package]] @@ -3213,6 +3300,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3287,6 +3384,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + [[package]] name = "phf" version = "0.13.1" @@ -3371,7 +3479,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.42.0", "serde", "time", ] @@ -3507,6 +3615,21 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -4451,6 +4574,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", @@ -4791,6 +4915,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.5.4" @@ -5002,6 +5141,20 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.55" @@ -5317,6 +5470,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5563,6 +5727,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -5682,6 +5916,12 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6143,6 +6383,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -6362,6 +6620,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -6446,6 +6724,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b7236a8a..a4e0645a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" tauri = { version = "2.11.5", features = ["tray-icon", "image-png", "macos-private-api"] } +tauri-plugin-clipboard-manager = "2" tauri-plugin-autostart = "2.5.1" tauri-plugin-single-instance = "2.4.3" tauri-plugin-updater = "2.10.1" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 18e89967..bd917969 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,6 +5,10 @@ fn main() { add_command_line_tools_swift_library_path(); let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", + "redeem_licence", + "paste_licence", + "remove_licence", + "set_licence_recovery", "get_switches", "begin_switch_practice", "get_switch_practice", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 7367a788..5781345d 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -7,6 +7,10 @@ "core:webview:allow-set-webview-zoom", "core:default", "allow-get-app-state", + "allow-redeem-licence", + "allow-paste-licence", + "allow-remove-licence", + "allow-set-licence-recovery", "allow-get-switches", "allow-begin-switch-practice", "allow-get-switch-practice", diff --git a/src-tauri/permissions/autogenerated/paste_licence.toml b/src-tauri/permissions/autogenerated/paste_licence.toml new file mode 100644 index 00000000..eb800c70 --- /dev/null +++ b/src-tauri/permissions/autogenerated/paste_licence.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-paste-licence" +description = "Enables the paste_licence command without any pre-configured scope." +commands.allow = ["paste_licence"] + +[[permission]] +identifier = "deny-paste-licence" +description = "Denies the paste_licence command without any pre-configured scope." +commands.deny = ["paste_licence"] diff --git a/src-tauri/permissions/autogenerated/redeem_licence.toml b/src-tauri/permissions/autogenerated/redeem_licence.toml new file mode 100644 index 00000000..6782884c --- /dev/null +++ b/src-tauri/permissions/autogenerated/redeem_licence.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-redeem-licence" +description = "Enables the redeem_licence command without any pre-configured scope." +commands.allow = ["redeem_licence"] + +[[permission]] +identifier = "deny-redeem-licence" +description = "Denies the redeem_licence command without any pre-configured scope." +commands.deny = ["redeem_licence"] diff --git a/src-tauri/permissions/autogenerated/remove_licence.toml b/src-tauri/permissions/autogenerated/remove_licence.toml new file mode 100644 index 00000000..7083270a --- /dev/null +++ b/src-tauri/permissions/autogenerated/remove_licence.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-remove-licence" +description = "Enables the remove_licence command without any pre-configured scope." +commands.allow = ["remove_licence"] + +[[permission]] +identifier = "deny-remove-licence" +description = "Denies the remove_licence command without any pre-configured scope." +commands.deny = ["remove_licence"] diff --git a/src-tauri/permissions/autogenerated/set_licence_recovery.toml b/src-tauri/permissions/autogenerated/set_licence_recovery.toml new file mode 100644 index 00000000..ff4ed228 --- /dev/null +++ b/src-tauri/permissions/autogenerated/set_licence_recovery.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-set-licence-recovery" +description = "Enables the set_licence_recovery command without any pre-configured scope." +commands.allow = ["set_licence_recovery"] + +[[permission]] +identifier = "deny-set-licence-recovery" +description = "Denies the set_licence_recovery command without any pre-configured scope." +commands.deny = ["set_licence_recovery"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c16011c..2cad0fa7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,8 @@ mod dwell; mod grid3; mod input; pub mod licence_key; +mod licensing; +use licensing::{paste_licence, redeem_licence, remove_licence, set_licence_recovery}; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] @@ -212,6 +214,11 @@ impl TraySnapshot { }; format!("Status: {label}") }; + let status = if state.licence.configured { + format!("{} · {}", status, state.licence.message) + } else { + status + }; Self { status, can_disconnect: state.connected_device_name.is_some() @@ -1873,12 +1880,14 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_clipboard_manager::init()) .manage(model) .manage(UpdateManager::::default()) .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .manage(account::Account::install()) .setup(move |app| { + licensing::install(app.handle()); app.manage(switch_practice::Controller::default()); switch_runtime::install(app.handle()); remote_scan::install(app.handle()); @@ -2003,6 +2012,10 @@ pub fn run() { configure_point_scan, set_keyboard_layout, get_app_state, + redeem_licence, + paste_licence, + remove_licence, + set_licence_recovery, check_accessibility, approve_pairing, reject_pairing, diff --git a/src-tauri/src/licensing.rs b/src-tauri/src/licensing.rs new file mode 100644 index 00000000..cb815864 --- /dev/null +++ b/src-tauri/src/licensing.rs @@ -0,0 +1,499 @@ +//! Offline entitlements. Separate from pairing, accounts, and synced settings. +use crate::licence_key; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Mutex}; +use tauri::{AppHandle, Emitter, Manager}; +const DAY: i64 = 86_400; +const TRIAL: i64 = 30 * DAY; +pub const LOCKED: &str = "Your trial has ended. Open Settings → Licence to enter a key."; +const SERVICE: &str = "com.enaboapps.switchify.pc.licence"; + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub configured: bool, + pub status: String, + pub days_remaining: u32, + pub licence_id: Option, + pub message: String, + pub storage_warning: bool, + #[serde(skip)] + pub access_until: Option, +} +impl View { + pub fn allowed(&self, now: i64) -> bool { + self.access_until.is_none_or(|end| now < end) + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + version: u8, + first_run: i64, + last_seen: i64, + revision: u64, + key: Option, +} +impl Record { + fn new(now: i64) -> Self { + Self { + version: 1, + first_run: now, + last_seen: now, + revision: 0, + key: None, + } + } + fn valid(&self) -> bool { + self.version == 1 + && self.first_run >= 0 + && self.first_run <= self.last_seen + && self + .key + .as_ref() + .is_none_or(|k| k.len() <= licence_key::MAX_KEY_BYTES) + } + fn merge(file: Option, secure: Option) -> Option { + match (file, secure) { + (Some(a), Some(b)) => { + let mut chosen = if a.revision >= b.revision { + a.clone() + } else { + b.clone() + }; + chosen.first_run = a.first_run.min(b.first_run); + chosen.last_seen = a.last_seen.max(b.last_seen); + Some(chosen) + } + (a, b) => a.or(b), + } + } + fn view(&self, keys: &[(&str, [u8; 32])], now: i64) -> View { + let effective = now.max(self.last_seen); + let mut view = View { + configured: true, + status: "trial".into(), + access_until: Some(self.first_run.saturating_add(TRIAL)), + ..View::default() + }; + if let Some(key) = &self.key { + match licence_key::verify(key, keys, effective) { + Ok(claims) => { + view.status = "licensed".into(); + view.licence_id = Some(claims.licence_id); + view.access_until = claims.expires_at; + view.message = "Licensed for this computer. No sign-in or internet connection is required.".into(); + return view; + } + Err(error) => view.message = error.to_string(), + } + } + let left = view.access_until.unwrap().saturating_sub(effective).max(0); + view.days_remaining = ((left + DAY - 1) / DAY) as u32; + if left == 0 { + view.status = "expired".into(); + view.access_until = Some(0); + if view.message.is_empty() { + view.message = LOCKED.into(); + } + } else if view.message.is_empty() { + view.message = format!("{} days left in your full trial.", view.days_remaining); + } + view + } +} +struct Data { + record: Option, + storage_warning: bool, + recovery: bool, + recovery_armed: bool, + cleanup_pending: bool, +} +pub struct Licensing { + data: Mutex, + path: PathBuf, + keys: Vec<(String, [u8; 32])>, + configured: bool, +} +fn now() -> i64 { + crate::state::now_ms() / 1000 +} +fn decode_record(text: &str) -> Result { + if text.len() > 16_384 { + return Err("Licence storage is unavailable.".into()); + } + let record: Record = + serde_json::from_str(text).map_err(|_| "Licence storage is unavailable.".to_string())?; + record + .valid() + .then_some(record) + .ok_or_else(|| "Licence storage is unavailable.".into()) +} +fn entry() -> Result { + keyring::Entry::new(SERVICE, "record").map_err(|_| "Licence storage is unavailable.".into()) +} +impl Licensing { + fn open(path: PathBuf) -> Self { + let config = option_env!("SWITCHIFY_LICENCE_PUBLIC_KEYS").unwrap_or(""); + let keys = serde_json::from_str::>(config) + .unwrap_or_default() + .into_iter() + .filter_map(|(id, value)| { + let bytes: [u8; 32] = URL_SAFE_NO_PAD.decode(value).ok()?.try_into().ok()?; + licence_key::valid_key_id(&id).then_some((id, bytes)) + }) + .collect::>(); + // No production trust anchors in development/CI. A nonempty invalid + // configuration fails closed, rather than granting an unlimited trial. + let configured = !config.is_empty(); + let mut file_error = false; + let file = match std::fs::read_to_string(&path) { + Ok(text) => match decode_record(&text) { + Ok(record) => Some(record), + Err(_) => { + file_error = true; + None + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => { + file_error = true; + None + } + }; + let mut secure_error = false; + let secure = if configured { + match entry().and_then(|entry| match entry.get_password() { + Ok(text) => decode_record(&text).map(Some), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err("Licence storage is unavailable.".into()), + }) { + Ok(record) => record, + Err(_) => { + secure_error = true; + None + } + } + } else { + None + }; + let record = Record::merge(file, secure) + .or_else(|| (!file_error && !secure_error).then(|| Record::new(now()))); + let service = Self { + data: Mutex::new(Data { + record, + storage_warning: file_error || secure_error, + recovery: false, + recovery_armed: false, + cleanup_pending: false, + }), + path, + keys, + configured, + }; + if configured { + let mut data = service.data.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(record) = &data.record { + data.storage_warning |= service.save(record).unwrap_or(true); + } + } + service + } + fn trust(&self) -> Vec<(&str, [u8; 32])> { + self.keys + .iter() + .map(|(id, key)| (id.as_str(), *key)) + .collect() + } + fn save(&self, record: &Record) -> Result { + let text = + serde_json::to_string(record).map_err(|_| "Could not save the licence.".to_string())?; + let parent = self.path.parent().ok_or("Could not save the licence.")?; + std::fs::create_dir_all(parent).map_err(|_| "Could not save the licence.".to_string())?; + let temp = parent.join(format!("licence-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temp) + .map_err(|_| "Could not save the licence.".to_string())?; + file.write_all(text.as_bytes()) + .map_err(|_| "Could not save the licence.".to_string())?; + file.sync_all() + .map_err(|_| "Could not save the licence.".to_string())?; + crate::storage::replace_file(&temp, &self.path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + return Err("Could not save the licence.".into()); + } + // File revision commits removal even if the keychain is temporarily locked. + Ok(entry() + .and_then(|entry| { + entry + .set_password(&text) + .map_err(|_| "Keychain unavailable.".into()) + }) + .is_err()) + } + pub fn view(&self) -> View { + if !self.configured { + return View { + status: "unconfigured".into(), + message: "Licensing is not enabled in this build.".into(), + ..View::default() + }; + } + let data = self.data.lock().unwrap_or_else(|p| p.into_inner()); + let mut view = if self.keys.is_empty() { + View { + configured: true, + status: "unavailable".into(), + message: "Licence verification is unavailable in this build. Contact support." + .into(), + access_until: Some(0), + ..View::default() + } + } else { + data.record.as_ref().map(|r|r.view(&self.trust(),now())).unwrap_or_else(|| View { configured:true,status:"unavailable".into(),message:"Unlock your keychain and restart Switchify PC, or enter your licence again.".into(),access_until:Some(0),..View::default() }) + }; + view.storage_warning = data.storage_warning; + view + } + fn redeem(&self, key: &str) -> Result<(), String> { + if !self.configured { + return Err("Licensing is not enabled in this build.".into()); + } + let mut data = self.data.lock().unwrap_or_else(|p| p.into_inner()); + let effective = now().max(data.record.as_ref().map_or(0, |r| r.last_seen)); + licence_key::verify(key, &self.trust(), effective).map_err(|e| e.to_string())?; + let mut record = data + .record + .clone() + .unwrap_or_else(|| Record::new(effective)); + record.key = Some(key.trim().to_owned()); + record.revision = record.revision.saturating_add(1); + record.last_seen = effective; + let warning = self.save(&record)?; + data.record = Some(record); + data.storage_warning = warning; + Ok(()) + } + fn checkpoint(&self) { + if !self.configured { + return; + } + let mut data = self.data.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(mut record) = data.record.clone() { + let time = now(); + if time.saturating_sub(record.last_seen) >= 60 { + record.last_seen = time; + data.storage_warning = self.save(&record).unwrap_or(true); + data.record = Some(record); + } + } + } + fn remove(&self) -> Result<(), String> { + let mut data = self.data.lock().unwrap_or_else(|p| p.into_inner()); + let mut record = data + .record + .clone() + .ok_or("Licence storage is unavailable.")?; + record.key = None; + record.revision = record.revision.saturating_add(1); + record.last_seen = now().max(record.last_seen); + let warning = self.save(&record)?; + data.record = Some(record); + data.storage_warning = warning; + Ok(()) + } +} +#[tauri::command] +pub fn paste_licence(app: AppHandle) -> Result { + use tauri_plugin_clipboard_manager::ClipboardExt; + let key = app + .clipboard() + .read_text() + .map_err(|_| "Could not read a licence from the clipboard.".to_string())?; + redeem_licence(app, key) +} +fn publish(app: &AppHandle) { + let view = app.state::().view(); + let model = app.state::(); + let changed = { + let mut data = model.shared.lock().unwrap_or_else(|p| p.into_inner()); + let changed = data.state.licence != view; + data.engine.set_access_deadline(view.access_until); + data.state.licence = view; + changed + }; + if changed { + crate::sync_tray_state(app, &model.snapshot()); + let _ = app.emit(crate::state::APP_STATE_EVENT, model.snapshot()); + } +} +pub fn allowed(app: &AppHandle) -> bool { + app.try_state::() + .is_none_or(|l| l.view().allowed(now())) +} +#[tauri::command] +pub fn redeem_licence(app: AppHandle, key: String) -> Result { + app.state::().redeem(&key)?; + publish(&app); + Ok(app.state::().view()) +} +#[tauri::command] +pub fn remove_licence(app: AppHandle) -> Result { + app.state::().remove()?; + publish(&app); + Ok(app.state::().view()) +} +#[tauri::command] +pub fn set_licence_recovery(app: AppHandle, active: bool) { + let service = app.state::(); + let mut data = service.data.lock().unwrap_or_else(|p| p.into_inner()); + data.recovery = active; +} +/// Called on the scanner's main-thread tick. Expiry cleanup runs before any +/// further scan action. While locked, local switches only navigate our own UI. +pub fn recovery_tick(app: &AppHandle) -> bool { + if allowed(app) { + if let Some(licence) = app.try_state::() { + let mut data = licence.data.lock().unwrap_or_else(|p| p.into_inner()); + if data.recovery_armed { + app.state::().stop(); + data.recovery_armed = false; + } + } + return false; + } + let licence = app.state::(); + let mut data = licence.data.lock().unwrap_or_else(|p| p.into_inner()); + let focused = app + .get_webview_window("main") + .is_some_and(|w| w.is_focused().unwrap_or(false)); + let controller = app.state::(); + if !data.recovery || !focused { + if data.recovery_armed { + controller.stop(); + data.recovery_armed = false; + } + return true; + } + if data.recovery_armed && !controller.active_generation(controller.generation()) { + data.recovery_armed = false; + } + if !data.recovery_armed { + if controller.enable(true).is_ok() { + data.recovery_armed = true; + } + return true; + } + let (events, _, generation) = controller.poll(app); + for event in events { + if let crate::switch_input::Event::Switch { + generation: g, + action: crate::switch_input::Action::Released, + .. + } = event + { + if g == generation { + let _ = app.emit("licence-recovery-select", ()); + } + } + } + true +} +pub fn install(app: &AppHandle) { + let path = app.state::().storage.licence_path(); + app.manage(Licensing::open(path)); + publish(app); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut was_allowed = true; + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let handle = app.clone(); + if app + .run_on_main_thread(move || { + handle.state::().checkpoint(); + publish(&handle); + let current = allowed(&handle); + let service = handle.state::(); + if was_allowed && !current { + service + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cleanup_pending = true; + crate::show_main_window(&handle, Some("settings")); + } + let pending = service + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cleanup_pending; + if pending { + let model = handle.state::(); + let result = crate::platform_disconnect_all(&handle, &model.shared); + if result.is_ok() { + service + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cleanup_pending = false; + } + } + }) + .is_err() + { + break; + } + was_allowed = allowed(&app); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn trial_uses_exact_boundary_and_never_restarts_on_rollback() { + let record = Record::new(1000); + assert_eq!(record.view(&[], 1000).days_remaining, 30); + assert!(record.view(&[], 1000 + TRIAL - 1).allowed(1000 + TRIAL - 1)); + assert!(!record.view(&[], 1000 + TRIAL).allowed(1000 + TRIAL)); + let mut advanced = record.clone(); + advanced.last_seen = 1000 + TRIAL; + assert_eq!(advanced.view(&[], 1000).status, "expired"); + } + #[test] + fn mirror_reconciliation_preserves_trial_and_removal() { + let old = Record { + key: Some("old".into()), + ..Record::new(1000) + }; + let new = Record { + key: None, + revision: 1, + ..Record::new(2000) + }; + let merged = Record::merge(Some(new), Some(old)).unwrap(); + assert_eq!(merged.first_run, 1000); + assert_eq!(merged.last_seen, 2000); + assert!(merged.key.is_none()); + } + #[test] + fn rejects_corrupt_trial_records() { + let mut record = Record::new(1000); + record.first_run = 2000; + assert!(decode_record(&serde_json::to_string(&record).unwrap()).is_err()); + } +} diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index a4364068..1877a419 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -439,6 +439,9 @@ pub fn stop_mouse_repeat(app: &AppHandle) { } pub fn dwell_click(_app: &AppHandle) -> Result<(), String> { + if !crate::licensing::allowed(_app) { + return Err(crate::licensing::LOCKED.into()); + } with_runtime(|runtime| { let input = runtime.input.as_mut().ok_or_else(|| { "Accessibility permission is required before the pointer can click.".to_string() diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 09778c9e..f9314f18 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -516,6 +516,7 @@ pub struct ProtocolEngine { tokens: HashMap, replay_cache: HashMap, connection_order: u64, + access_deadline: Option, } impl ProtocolEngine { @@ -527,9 +528,14 @@ impl ProtocolEngine { tokens: HashMap::new(), replay_cache: HashMap::new(), connection_order: 0, + access_deadline: None, } } + pub fn set_access_deadline(&mut self, deadline: Option) { + self.access_deadline = deadline; + } + #[cfg_attr(target_os = "windows", allow(dead_code))] pub fn desktop_id(&self) -> &str { &self.desktop_id @@ -773,6 +779,24 @@ impl ProtocolEngine { } }; + if self + .access_deadline + .is_some_and(|deadline| now_ms / 1000 >= deadline) + && !matches!( + command_type, + "connection.ping" + | "pointer.profile" + | "switch.session.stop" + | "switch.profile.list" + ) + { + return Ok(EngineEvent::Response(error_response( + Some(&validated.id), + "invalid_state", + crate::licensing::LOCKED, + ))); + } + match command_type { "connection.ping" => { let device_name = match validated.payload.get("deviceName") { @@ -1916,6 +1940,34 @@ mod tests { assert!(!is_safe_typed_text("bad\u{0085}")); } + #[test] + fn expired_licence_blocks_input_but_preserves_authenticated_connection() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine.set_paired_token("remote-1".into(), TOKEN.into()); + engine.set_access_deadline(Some(NOW / 1000)); + for (id, kind, payload, expected) in [ + ( + "locked-text", + "keyboard.typeText", + json!({"text":"private"}), + false, + ), + ("locked-mouse", "mouse.move", json!({"dx":1,"dy":1}), false), + ("locked-ping", "connection.ping", json!({}), true), + ] { + let mut command = json!({"version":1,"id":id,"deviceId":"remote-1","timestamp":NOW,"type":kind,"payload":payload}); + sign(&mut command, TOKEN, SlashEscaping::AndroidHtmlSafe); + let event = engine.process_message(&command.to_string(), NOW).unwrap(); + if expected { + assert!(matches!(event, EngineEvent::AuthenticatedConnection(_))); + } else { + assert!( + matches!(event,EngineEvent::Response(response) if response.contains("invalid_state") && !response.contains("private")) + ); + } + } + } + #[test] fn authenticated_ping_emits_connection_events_for_legacy_and_named_payloads() { let mut engine = ProtocolEngine::new("desktop-1".into()); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 2147dead..7529c857 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -425,6 +425,9 @@ pub fn configure( } /// Why scanning should stay off, if it should. Pure, so it is safe every tick. fn wanted(app: &AppHandle, config: &A::Config) -> Result<(), String> { + if !crate::licensing::allowed(app) { + return Err(crate::licensing::LOCKED.into()); + } if !cfg!(any(target_os = "windows", target_os = "macos")) { return Err("Switch scanning is not available on this platform.".into()); } @@ -546,6 +549,9 @@ fn input_active(app: &AppHandle, generation: u64, remote: bool) -> bool { ) } fn switch(app: &AppHandle, action: Action, input_generation: u64, remote: bool) { + if !crate::licensing::allowed(app) { + return; + } let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { return; @@ -643,6 +649,9 @@ fn dispatch( input_generation: u64, remote: bool, ) -> Result<(), String> { + if !crate::licensing::allowed(app) { + return Err(crate::licensing::LOCKED.into()); + } A::validate_environment(app, environment)?; let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation, remote) { @@ -887,6 +896,15 @@ fn render( Ok(()) } fn tick(app: &AppHandle) { + if !crate::licensing::allowed(app) { + let enabled = app.state::>().enabled.load(Ordering::SeqCst); + if enabled { + disable::(app, crate::licensing::LOCKED); + } + crate::licensing::recovery_tick(app); + return; + } + crate::licensing::recovery_tick(app); if crate::scan_menu_bar::take_click() { crate::show_main_window(app, Some(crate::scan_menu_bar::DESTINATION)); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 2fe62eaf..5953f35d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -365,6 +365,7 @@ pub fn built_in_profiles(include_grid3: bool) -> Vec { #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AppState { + pub licence: crate::licensing::View, pub bluetooth: BluetoothState, pub accessibility: AccessibilityState, pub desktop_id: String, @@ -465,6 +466,7 @@ impl AppModel { engine, profiles, state: AppState { + licence: crate::licensing::View::default(), bluetooth: BluetoothState::Initializing, accessibility: AccessibilityState::Required, desktop_id, diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index b99241dd..423fc65b 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -354,6 +354,10 @@ impl AppStorage { self.path.with_file_name(DIAGNOSTIC_HISTORY_FILE) } + pub fn licence_path(&self) -> PathBuf { + self.path.with_file_name("licence.json") + } + pub fn telemetry_path(&self) -> PathBuf { self.path.with_file_name(TELEMETRY_FILE) } diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 14c43cff..c2376572 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -2189,6 +2189,9 @@ pub fn stop_mouse_repeat(app: &AppHandle) { } pub fn dwell_click(app: &AppHandle) -> Result<(), String> { + if !crate::licensing::allowed(app) { + return Err(crate::licensing::LOCKED.into()); + } stop_all_repeats(app); with_runtime_input(execute_dwell_click).map(|_| ()) } @@ -2435,14 +2438,17 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin if let Some(notifications) = notifications { notifications.disconnect(); } - if let Some(runtime) = runtime() + let release = if let Some(runtime) = runtime() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .as_mut() { - let _ = runtime.input.release_all(); + let release = runtime.input.release_all(); runtime.input.end_control_session(); - } + release + } else { + Ok(()) + }; pairing_centrals() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -2456,7 +2462,7 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin model.state.connected_device_name = None; drop(model); emit_state(app, shared); - Ok(()) + release } fn reset_transport(app: &AppHandle, shared: &SharedModel, bluetooth: BluetoothState) { diff --git a/src/App.tsx b/src/App.tsx index 85e7951e..1e392b40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -891,6 +891,7 @@ export function App() { 0 || switches.capturing || !!switches.state?.capture.active}>
0}> + {state.licence?.configured &&

{state.licence.message} See Settings → Licence.

} {error &&
{error}
}

{updateNotice}

diff --git a/src/settings/AboutSection.tsx b/src/settings/AboutSection.tsx index ca73da32..7ad44a8a 100644 --- a/src/settings/AboutSection.tsx +++ b/src/settings/AboutSection.tsx @@ -2,5 +2,5 @@ import type { AppState } from "../types"; import { SettingGroup } from "./controls"; export function AboutSection({ state }: { state: AppState }) { - return

Switchify PC is free and open source software, licensed under the GNU Affero General Public License v3. It comes with no warranty.

Source code · Licence and credits · Online guide

; + return

Switchify PC is open source software, licensed under the GNU Affero General Public License v3. It comes with no warranty.

Source code · Licence and credits · Online guide

; } diff --git a/src/settings/LicenceSection.test.tsx b/src/settings/LicenceSection.test.tsx new file mode 100644 index 00000000..3e302e02 --- /dev/null +++ b/src/settings/LicenceSection.test.tsx @@ -0,0 +1,33 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { browserState } from "../api"; +import { LicenceSection } from "./LicenceSection"; +import type { LicenceView } from "../types"; +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +import { invoke } from "@tauri-apps/api/core"; +const licensed: LicenceView = { configured: true, status: "licensed", daysRemaining: 0, licenceId: "id", message: "Licensed offline.", storageWarning: false }; +afterEach(() => { cleanup(); vi.clearAllMocks(); }); +it("keeps a valid licence visible when a replacement key is rejected", async () => { + vi.mocked(invoke).mockRejectedValue("This licence key is invalid."); + render(); + fireEvent.change(screen.getByLabelText("Licence key"), { target: { value: "invalid-key" } }); + fireEvent.click(screen.getByRole("button", { name: "Activate key" })); + expect(await screen.findByRole("alert")).toHaveTextContent("This licence key is invalid."); + expect(screen.getByText("Licensed offline.")).toBeInTheDocument(); +}); +it("only removes a licence after the local confirmation", async () => { + vi.mocked(invoke).mockResolvedValue({ ...licensed, status: "expired", licenceId: null, message: "Trial ended." }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove licence from this computer" })); + expect(invoke).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Remove key" })); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("remove_licence", undefined)); + expect(await screen.findByText("Trial ended.")).toBeInTheDocument(); +}); +it("does not read the clipboard until Paste and activate is selected", async () => { + vi.mocked(invoke).mockResolvedValue(licensed); + render(); + expect(invoke).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Paste and activate" })); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("paste_licence", undefined)); +}); diff --git a/src/settings/LicenceSection.tsx b/src/settings/LicenceSection.tsx new file mode 100644 index 00000000..66f33d13 --- /dev/null +++ b/src/settings/LicenceSection.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import type { AppState, LicenceView } from "../types"; +import { Button } from "../ui/controls"; +import { SettingGroup } from "./controls"; + +export function LicenceSection({ state }: { state: AppState }) { + const [updated, setUpdated] = useState(null); + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState(false); + const scope = useRef(null); + const highlighted = useRef(null); + const view = updated ?? state.licence; + const locked = view?.configured && ["expired", "unavailable"].includes(view.status); + useEffect(() => { setUpdated(null); }, [state.licence]); + useEffect(() => { + if (!locked || !("__TAURI_INTERNALS__" in window)) return; + void invoke("set_licence_recovery", { active: true }); + // Automatic scanning stays inside the licence page, without native mouse + // or keyboard injection. Any configured local switch selects the focus. + let index = -1; + const timer = window.setInterval(() => { + const buttons = Array.from(scope.current?.querySelectorAll("button:not(:disabled)") ?? []); + if (!buttons.length) return; + index = (index + 1) % buttons.length; + highlighted.current = buttons[index]; + buttons[index].focus(); + }, 1500); + let alive = true; + const stop = listen("licence-recovery-select", () => { if (alive) highlighted.current?.click(); }); + return () => { + alive = false; + window.clearInterval(timer); + highlighted.current = null; + void stop.then((unlisten) => unlisten()); + void invoke("set_licence_recovery", { active: false }); + }; + }, [locked]); + const run = async (command: string, args?: Record) => { + setBusy(true); setError(null); + try { + const next = await invoke(command, args); + setUpdated(next); setKey(""); setConfirm(false); + } catch (e) { setError(String(e)); } + finally { setBusy(false); } + }; + return +
+

{view?.message ?? "Licensing is not enabled in this build."}

+ {view?.storageWarning &&

Your licence storage backup is unavailable. Unlock your keychain and restart Switchify PC.

} + {locked &&

Switch control here stays available. Buttons receive focus automatically; press any configured local switch to choose one. Copy your key first, then choose Paste and activate.

} + {view?.configured && <> + {view.licenceId &&

Licence ID: {view.licenceId}

} +

€100 one-time purchase for one computer. A key works offline, without a Switchify account.

+ +