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..f3002bb5 --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,38 @@ +# 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. +The file is checkpointed about once a minute; the keychain mirror is refreshed +at launch and whenever a key is entered or removed. While the mirror is known +to be stale the checkpoint retries it with exponential backoff (one minute +doubling to about an hour), merging the keychain record before writing so an +earlier trial or a newer removal stored there still wins. +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. +If the keychain record cannot be read and the file is missing, a trial starts +and the earlier keychain record wins again once it is readable; while it stays +unreadable the new file is trusted, the same best-effort gap as deleting it. + +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..afbb47d1 100644 --- a/site/src/content/docs/licence.md +++ b/site/src/content/docs/licence.md @@ -1,13 +1,39 @@ --- 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. + +Using the mouse, pressing Tab, or typing in the key field pauses automatic +button scanning on the Licence page. Choose **Resume automatic button +scanning** when you want to use your local switches again. + +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..7d2f1bfc 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -3,8 +3,14 @@ use std::process::Command; fn main() { add_command_line_tools_swift_library_path(); + // Compiled into the licence verifier; rebuild when the public issuer set changes. + println!("cargo:rerun-if-env-changed=SWITCHIFY_LICENCE_PUBLIC_KEYS"); 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..7e8787ff --- /dev/null +++ b/src-tauri/src/licensing.rs @@ -0,0 +1,754 @@ +//! 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 = match view.days_remaining { + 1 => "1 day left in your full trial.".into(), + days => format!("{days} days left in your full trial."), + }; + } + view + } +} +struct Data { + record: Option, + storage_warning: bool, + recovery: bool, + recovery_armed: bool, + cleanup_pending: bool, + last_allowed: bool, + /// Cached by `view` so hot paths skip signature verification. Starts + /// closed; `open` seeds it before the service is managed. + access_until: Option, + /// Consecutive failed keychain retries; the next retry waits + /// `60 * 2^min(n, 6)` seconds so a locked keychain is not prompted every minute. + secure_failures: u32, + next_secure_retry: i64, +} +impl Data { + fn observe_access(&mut self, current: bool) -> bool { + let expired = self.last_allowed && !current; + self.last_allowed = current; + self.cleanup_pending |= expired; + expired + } + /// Whether this checkpoint should also attempt the keychain mirror. + fn retry_secure_due(&self, time: i64) -> bool { + self.storage_warning && time >= self.next_secure_retry + } + /// Apply a checkpoint `save` outcome. `Err` is a file failure (the keychain + /// was not tried), `Ok(true)` a keychain failure, `Ok(false)` success. + /// A warning only clears when the keychain was actually retried, so a + /// stale mirror keeps its warning across the non-retry minutes. + fn record_checkpoint(&mut self, time: i64, retried_secure: bool, outcome: Result) { + match outcome { + Err(()) => self.storage_warning = true, + Ok(true) => { + self.storage_warning = true; + self.secure_failures = self.secure_failures.saturating_add(1); + self.next_secure_retry = time + 60 * (1i64 << self.secure_failures.min(6)); + } + Ok(false) if retried_secure => { + self.storage_warning = false; + self.secure_failures = 0; + self.next_secure_retry = 0; + } + Ok(false) => {} + } + } +} +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()) +} +/// A missing file with a working keychain, or a missing file with an +/// unavailable keychain, starts the trial: nothing readable says otherwise, and +/// the next launch with the keychain back reconciles to the earliest first run. +/// An unreadable file fails closed until it is repaired. +fn initial_record( + file: Option, + secure: Option, + file_error: bool, + now: i64, +) -> Option { + Record::merge(file, secure).or_else(|| (!file_error).then(|| Record::new(now))) +} +/// The keychain mirror, if present. Errors cover a locked or denied keychain +/// and a corrupt record alike; callers treat both as "unavailable for now". +fn read_secure() -> Result, String> { + 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()), + }) +} +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 read_secure() { + Ok(record) => record, + Err(_) => { + secure_error = true; + None + } + } + } else { + None + }; + let record = initial_record(file, secure, file_error, now()); + let service = Self { + data: Mutex::new(Data { + record, + storage_warning: file_error || secure_error, + recovery: false, + recovery_armed: false, + cleanup_pending: false, + last_allowed: true, + access_until: Some(0), + secure_failures: 0, + next_secure_retry: 0, + }), + 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, true).unwrap_or(true); + } + } + service.seed_access(); + service + } + /// Observe from the real state at launch. An install that is already + /// expired has no active input to clean up and must not raise the window + /// on every start, including hidden login starts. + fn cached_allowed(&self, now: i64) -> bool { + if !self.configured { + return true; + } + self.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .access_until + .is_none_or(|end| now < end) + } + fn seed_access(&self) { + let allowed = self.view().allowed(now()); + self.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .last_allowed = allowed; + } + fn trust(&self) -> Vec<(&str, [u8; 32])> { + self.keys + .iter() + .map(|(id, key)| (id.as_str(), *key)) + .collect() + } + /// Writes the file, and the keychain mirror when `secure` is set. Returns + /// whether the keychain write failed. The per-minute checkpoint only + /// touches the file; the mirror is refreshed at launch and on key changes. + fn save(&self, record: &Record, secure: bool) -> 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()); + } + if !secure { + return Ok(false); + } + // 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 mut 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; + data.access_until = view.access_until; + 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, true)?; + let time = record.last_seen; + data.record = Some(record); + data.record_checkpoint(time, true, Ok(warning)); + drop(data); + self.view(); + 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; + // Retry the keychain mirror only while it is known to be stale, so the + // healthy case costs no keychain writes and a locked keychain heals. + let retry_secure = data.retry_secure_due(time); + if retry_secure { + // Merge what the keychain already holds first, so an earlier + // trial or a newer removal is never overwritten by this session. + if let Ok(Some(stored)) = read_secure() { + record = Record::merge(Some(record), Some(stored)) + .unwrap_or_else(|| unreachable!("merge of two records is always Some")); + } + } + let outcome = self.save(&record, retry_secure).map_err(|_| ()); + data.record_checkpoint(time, retry_secure, outcome); + 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, true)?; + let time = record.last_seen; + data.record = Some(record); + data.record_checkpoint(time, true, Ok(warning)); + drop(data); + self.view(); + 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()); + } +} +/// Cheap check for scanning ticks and input dispatch. The cached deadline is +/// refreshed by every `view` call: at launch, each second from `publish`, and +/// after a key is redeemed or removed. +pub fn allowed(app: &AppHandle) -> bool { + app.try_state::() + .is_none_or(|l| l.cached_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 { + 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::(); + let expired = service + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .observe_access(current); + if expired { + 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; + } + } + }); +} + +#[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_eq!( + record.view(&[], 1000).message, + "30 days left in your full trial." + ); + assert_eq!( + record.view(&[], 1000 + TRIAL - 1).message, + "1 day left in your full trial." + ); + 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 main_thread_observations_cannot_skip_cleanup_between_allowed_and_expired() { + let mut data = Data { + record: None, + storage_warning: false, + recovery: false, + recovery_armed: false, + cleanup_pending: false, + last_allowed: true, + access_until: Some(0), + secure_failures: 0, + next_secure_retry: 0, + }; + assert!(!data.observe_access(true)); + // An expiry or removal between callbacks cannot update last_allowed. + assert!(data.observe_access(false)); + assert!(data.cleanup_pending); + assert!(!data.observe_access(false)); + assert!(data.cleanup_pending); // failed cleanup stays pending + data.cleanup_pending = false; + assert!(!data.observe_access(true)); + assert!(data.observe_access(false)); + } + fn configured_service(record: Record) -> Licensing { + Licensing { + data: Mutex::new(Data { + record: Some(record), + storage_warning: false, + recovery: false, + recovery_armed: false, + cleanup_pending: false, + last_allowed: true, + access_until: Some(0), + secure_failures: 0, + next_secure_retry: 0, + }), + path: std::env::temp_dir().join("switchify-licence-test-never-written.json"), + keys: vec![("test".into(), [0; 32])], + configured: true, + } + } + #[test] + fn launching_an_expired_install_does_not_queue_cleanup() { + let mut expired = Record::new(0); + expired.last_seen = TRIAL; + let service = configured_service(expired); + service.seed_access(); + let mut data = service.data.lock().unwrap(); + assert!(!data.last_allowed); + assert!(!data.observe_access(false)); + assert!(!data.cleanup_pending); + drop(data); + let service = configured_service(Record::new(now())); + service.seed_access(); + let mut data = service.data.lock().unwrap(); + assert!(data.last_allowed); + assert!(data.observe_access(false)); + } + #[test] + fn cached_access_follows_the_last_computed_view() { + let service = configured_service(Record::new(now())); + service.view(); + assert!(service.cached_allowed(now())); + let mut expired = Record::new(0); + expired.last_seen = TRIAL; + service.data.lock().unwrap().record = Some(expired); + assert!(service.cached_allowed(now()), "stale until refreshed"); + service.view(); + assert!(!service.cached_allowed(now())); + } + #[test] + fn fresh_install_starts_the_trial_even_when_the_keychain_is_unavailable() { + let fresh = initial_record(None, None, false, 5000).unwrap(); + assert_eq!(fresh.first_run, 5000); + assert!(initial_record(None, None, true, 5000).is_none()); + let older = Record::new(1000); + let kept = initial_record(Some(fresh), Some(older), false, 9000).unwrap(); + assert_eq!( + kept.first_run, 1000, + "the keychain's earlier trial wins once it is back" + ); + } + #[test] + fn unreadable_file_keeps_the_keychain_record_and_fails_closed_without_one() { + let old = Record::new(1000); + let kept = initial_record(None, Some(old.clone()), true, 9000).unwrap(); + assert_eq!(kept.first_run, 1000); + assert!(initial_record(None, None, true, 9000).is_none()); + } + #[test] + fn file_only_save_never_reports_a_keychain_failure() { + let dir = std::env::temp_dir().join(format!("switchify-licence-{}", uuid::Uuid::new_v4())); + let mut service = configured_service(Record::new(1000)); + service.path = dir.join("licence.json"); + assert_eq!(service.save(&Record::new(1000), false), Ok(false)); + let saved = decode_record(&std::fs::read_to_string(&service.path).unwrap()).unwrap(); + assert_eq!(saved.first_run, 1000); + let _ = std::fs::remove_dir_all(dir); + } + #[test] + fn stale_keychain_keeps_its_warning_and_backs_off_until_a_retry_succeeds() { + let mut data = Data { + record: None, + storage_warning: true, + recovery: false, + recovery_armed: false, + cleanup_pending: false, + last_allowed: true, + access_until: Some(0), + secure_failures: 0, + next_secure_retry: 0, + }; + // Retries at 60, then 60 + 120, then + 240 ... capped at 64 minutes. + let mut time = 60; + let mut expected = vec![]; + for _ in 0..8 { + assert!(data.retry_secure_due(time)); + data.record_checkpoint(time, true, Ok(true)); + expected.push(time); + // Non-retry minutes keep the warning and the schedule untouched. + assert!(!data.retry_secure_due(time + 60)); + data.record_checkpoint(time + 60, false, Ok(false)); + assert!(data.storage_warning); + time = data.next_secure_retry; + } + assert_eq!(expected, [60, 180, 420, 900, 1860, 3780, 7620, 11460]); + // A file failure sets the warning without touching the keychain schedule. + let scheduled = data.next_secure_retry; + data.record_checkpoint(time, false, Err(())); + assert!(data.storage_warning); + assert_eq!(data.next_secure_retry, scheduled); + // The first successful keychain retry clears everything. + data.record_checkpoint(time, true, Ok(false)); + assert!(!data.storage_warning); + assert_eq!(data.secure_failures, 0); + assert!(!data.retry_secure_due(time + 60)); + } + #[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..31bb1606 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,28 @@ 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" + | "mouse.repeat.stop" + | "mouse.dragEnd" + | "keyboard.modifierUp" + | "connection.disconnecting" + ) + { + 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 +1944,76 @@ 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-drag", + "mouse.dragStart", + json!({"button":"left"}), + false, + ), + ( + "locked-modifier", + "keyboard.modifierDown", + json!({"key":"ctrl"}), + 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 licence_expiry_allows_authenticated_stop_and_disconnect_after_input_started() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine.set_paired_token("remote-1".into(), TOKEN.into()); + let mut movement = json!({"version":1,"id":"before-expiry","deviceId":"remote-1","timestamp":NOW,"type":"mouse.move","payload":{"dx":1,"dy":1}}); + sign(&mut movement, TOKEN, SlashEscaping::AndroidHtmlSafe); + assert!(matches!( + engine.process_message(&movement.to_string(), NOW).unwrap(), + EngineEvent::MouseMove(_) + )); + engine.set_access_deadline(Some(NOW / 1000)); + for (id, kind, payload) in [ + ("stop-expired", "mouse.repeat.stop", json!({})), + ("drag-expired", "mouse.dragEnd", json!({})), + ( + "modifier-expired", + "keyboard.modifierUp", + json!({"key":"ctrl"}), + ), + ("disconnect-expired", "connection.disconnecting", json!({})), + ] { + let mut command = json!({"version":1,"id":id,"deviceId":"remote-1","timestamp":NOW,"type":kind,"payload":payload}); + sign(&mut command, TOKEN, SlashEscaping::AndroidHtmlSafe); + assert!(matches!( + engine.process_message(&command.to_string(), NOW).unwrap(), + EngineEvent::Desktop(_) + )); + } + } + #[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.test.tsx b/src/App.test.tsx index b76081ca..a055cf94 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -388,6 +388,20 @@ describe("Switchify PC shell", () => { expect(screen.queryByRole("status", { name: "Application update" })).not.toBeInTheDocument(); }); + it("shows the licence notice only while the computer is not licensed", async () => { + const licence = { configured: true, status: "licensed", daysRemaining: 0, licenceId: "id", message: "Licensed for this computer.", storageWarning: false }; + browserState.licence = licence; + const { unmount } = render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + expect(screen.queryByText(/See Settings → Licence/)).not.toBeInTheDocument(); + unmount(); + browserState.licence = { ...licence, status: "trial", licenceId: null, message: "12 days left in your full trial." }; + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + expect(screen.getByText(/See Settings → Licence/)).toHaveTextContent("12 days left in your full trial. See Settings → Licence."); + delete browserState.licence; + }); + it("renders the connection and permission state", async () => { render(); expect(await screen.findByRole("heading", { name: "Switchify PC" })).toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 85e7951e..54eb7cdd 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.status !== "licensed" &&

{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..981ca266 --- /dev/null +++ b/src/settings/LicenceSection.test.tsx @@ -0,0 +1,58 @@ +import { act, 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() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockResolvedValue(() => {}) })); +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)); +}); + +it("pauses recovery scanning while a keyboard user enters a key", async () => { + vi.useFakeTimers(); + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + try { + render(); + await act(async () => { vi.advanceTimersByTime(1500); }); + expect(screen.getByRole("button", { name: "Paste and activate" })).toHaveFocus(); + const field = screen.getByLabelText("Licence key"); + act(() => field.focus()); + await act(async () => { vi.advanceTimersByTime(3000); }); + expect(field).toHaveFocus(); + expect(invoke).toHaveBeenCalledWith("set_licence_recovery", { active: false }); + fireEvent.change(field, { target: { value: "a manually entered key" } }); + fireEvent.click(screen.getByRole("button", { name: "Resume automatic button scanning" })); + await act(async () => { vi.advanceTimersByTime(1500); }); + expect(screen.getByRole("button", { name: "Activate key" })).toHaveFocus(); + expect(field).toHaveValue("a manually entered key"); + } finally { + cleanup(); + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + vi.useRealTimers(); + } +}); diff --git a/src/settings/LicenceSection.tsx b/src/settings/LicenceSection.tsx new file mode 100644 index 00000000..5effa6ea --- /dev/null +++ b/src/settings/LicenceSection.tsx @@ -0,0 +1,91 @@ +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 [recoveryPaused, setRecoveryPaused] = 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 || recoveryPaused || !("__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. + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab") setRecoveryPaused(true); + }; + window.addEventListener("keydown", onKeyDown); + 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); + window.removeEventListener("keydown", onKeyDown); + highlighted.current = null; + void stop.then((unlisten) => unlisten()); + void invoke("set_licence_recovery", { active: false }); + }; + }, [locked, recoveryPaused]); + 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 +
setRecoveryPaused(true)} onFocusCapture={(event) => { + if (event.target.matches("textarea,input,select")) setRecoveryPaused(true); + }}> +

{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. Using the mouse, Tab key or key entry field pauses automatic button scanning.

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

Licence ID: {view.licenceId}

} +

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

+ +