From 1600bfc9c909e1e8be2d656143b5d543668dd97b Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 31 Oct 2026 10:54:31 +0000 Subject: [PATCH] Add test licence checkout and remove in-app price --- package-lock.json | 10 ++++++ package.json | 1 + site/src/content/docs/licence.md | 13 +++++--- src-tauri/Cargo.lock | 53 +++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/main.json | 4 +++ src-tauri/src/lib.rs | 5 +++ src-tauri/tests/config.rs | 31 +++++++++++++++++- src/App.tsx | 2 ++ src/licensing/BuyLicence.test.tsx | 26 +++++++++++++++ src/licensing/BuyLicence.tsx | 27 ++++++++++++++++ src/settings/LicenceSection.tsx | 4 ++- src/ui/app.css | 7 ++++ 13 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/licensing/BuyLicence.test.tsx create mode 100644 src/licensing/BuyLicence.tsx diff --git a/package-lock.json b/package-lock.json index fd0b919d..63bf2fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.3.0", "dependencies": { "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-opener": "^2.7.2", "lucide-react": "^1.61.0", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -1200,6 +1201,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.2.tgz", + "integrity": "sha512-LyybP1VSzTMQiaFYcsbAse47KpivlyXdLZMQzGLEnkgQacGESnpjAfFoQ6h5sqWgAdZDH9szYjPzHMpv0Sz+TQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", diff --git a/package.json b/package.json index 4a29c60f..18e342e7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-opener": "^2.7.2", "lucide-react": "^1.61.0", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/site/src/content/docs/licence.md b/site/src/content/docs/licence.md index ca30ce3f..2a9856de 100644 --- a/site/src/content/docs/licence.md +++ b/site/src/content/docs/licence.md @@ -9,8 +9,8 @@ Switchify PC is open source software. It is licensed under the [GNU Affero Gener ## 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, +Builds with licensing enabled provide a full 30-day trial on this computer. +A Switchify PC licence covers 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. @@ -19,6 +19,11 @@ During the trial, a notice at the top of the app shows how many days remain. It also points to **Settings → Licence** for entering your key. The notice disappears once this computer is licensed. +**Buy licence** opens checkout in your default browser. It appears in the +trial notice and on **Settings → Licence** when this computer is not licensed. +The current link is a Stripe **test checkout**, marked in the app; it does not +take a real payment. An internet connection is needed to open checkout. + Open **Settings → Licence** to paste a key, choose **Paste and activate**, or choose **Import licence file** to open a saved text licence file. Your trial days or active licence appear in the status panel at the top of this page. @@ -38,8 +43,8 @@ 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. +Live purchases are not available yet. The test checkout is for validating +licence delivery before release. 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 24a82bcc..534d55a9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2391,6 +2391,25 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3263,6 +3282,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4575,6 +4605,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", @@ -4930,6 +4961,28 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows 0.62.2", + "zbus", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.5.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a4e0645a..b67d4160 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ semver = "1" tiny-skia = "0.12.0" tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } uuid = { version = "1", features = ["v4", "serde"] } +tauri-plugin-opener = "2.7.2" [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 5781345d..bcd046a7 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -4,6 +4,10 @@ "description": "Application capability for the main Switchify PC window", "windows": ["main"], "permissions": [ + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://buy.stripe.com/test_dRm28t9gZ2sK3wKezb87K01" }] + }, "core:webview:allow-set-webview-zoom", "core:default", "allow-get-app-state", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2cad0fa7..225a0670 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1881,6 +1881,11 @@ pub fn run() { ) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin( + tauri_plugin_opener::Builder::new() + .open_js_links_on_click(false) + .build(), + ) .manage(model) .manage(UpdateManager::::default()) .manage(PendingProfileExit::default()) diff --git a/src-tauri/tests/config.rs b/src-tauri/tests/config.rs index 19a565dd..01c686ec 100644 --- a/src-tauri/tests/config.rs +++ b/src-tauri/tests/config.rs @@ -61,7 +61,12 @@ fn permission_names(capability: &serde_json::Value) -> Vec<&str> { .as_array() .expect("capability permissions") .iter() - .map(|permission| permission.as_str().expect("permission name")) + .map(|permission| { + permission + .as_str() + .or_else(|| permission["identifier"].as_str()) + .expect("permission name") + }) .collect() } @@ -112,6 +117,30 @@ fn main_window_has_application_commands_and_core_defaults() { } } +#[test] +fn purchase_browser_access_is_limited_to_the_test_checkout() { + let main = capability(include_str!("../capabilities/main.json")); + let opener: Vec<_> = main["permissions"] + .as_array() + .unwrap() + .iter() + .filter(|permission| { + permission.as_str().unwrap_or("").starts_with("opener:") + || permission["identifier"] + .as_str() + .unwrap_or("") + .starts_with("opener:") + }) + .collect(); + assert_eq!( + opener, + vec![&serde_json::json!({ + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://buy.stripe.com/test_dRm28t9gZ2sK3wKezb87K01" }] + })] + ); +} + #[test] fn modifier_overlay_has_only_its_minimal_event_and_command_permissions() { let overlay = capability(include_str!("../capabilities/modifier-overlay.json")); diff --git a/src/App.tsx b/src/App.tsx index d004a875..b1473d15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { useSwitches, type SwitchController } from "./scanning/useSwitches"; import { lastModeName, selectOpensHome, useScanning, type ScanningController } from "./scanning/useScanning"; import { areaOptions } from "./scanning/preferences"; import { homeStatus } from "./homeStatus"; +import { BuyLicence } from "./licensing/BuyLicence"; import { interfaceModeAdvice, reservedKeyAdvice } from "./help/keyAvailability"; import { SwitchesSection } from "./settings/SwitchesSection"; @@ -898,6 +899,7 @@ export function App() {

{state.licence.message}

See Settings → Licence.

+ } {error &&
{error}
} diff --git a/src/licensing/BuyLicence.test.tsx b/src/licensing/BuyLicence.test.tsx new file mode 100644 index 00000000..1a45c3d4 --- /dev/null +++ b/src/licensing/BuyLicence.test.tsx @@ -0,0 +1,26 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { BuyLicence, licencePurchaseUrl } from "./BuyLicence"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +it("opens the approved test checkout only after the button is selected", async () => { + vi.mocked(openUrl).mockResolvedValue(); + render(); + expect(openUrl).not.toHaveBeenCalled(); + expect(screen.getByText(/Test checkout/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Buy licence" })); + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(licencePurchaseUrl)); +}); + +it("reports a browser launch failure and allows another attempt", async () => { + vi.mocked(openUrl).mockRejectedValueOnce(new Error("unavailable")).mockResolvedValueOnce(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Buy licence" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not open checkout"); + fireEvent.click(screen.getByRole("button", { name: "Buy licence" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + expect(openUrl).toHaveBeenCalledTimes(2); +}); diff --git a/src/licensing/BuyLicence.tsx b/src/licensing/BuyLicence.tsx new file mode 100644 index 00000000..e5c293cb --- /dev/null +++ b/src/licensing/BuyLicence.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ExternalLink } from "lucide-react"; +import { Button } from "../ui/controls"; + +// Test checkout only. Replace this URL and the exact opener scope together +// after live fulfilment is approved; v1.4.0 is still held. +export const licencePurchaseUrl = "https://buy.stripe.com/test_dRm28t9gZ2sK3wKezb87K01"; + +export function BuyLicence() { + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const buy = async () => { + setBusy(true); + setFailed(false); + try { await openUrl(licencePurchaseUrl); } + catch { setFailed(true); } + finally { setBusy(false); } + }; + return
+ +

Test checkout · no real payment

+ {failed &&

Could not open checkout in your browser. Try again when your browser is available.

} +
; +} diff --git a/src/settings/LicenceSection.tsx b/src/settings/LicenceSection.tsx index 8a92313d..93bd320e 100644 --- a/src/settings/LicenceSection.tsx +++ b/src/settings/LicenceSection.tsx @@ -5,6 +5,7 @@ import { Check, KeyRound } from "lucide-react"; import type { AppState, LicenceView } from "../types"; import { Button } from "../ui/controls"; import { SettingGroup } from "./controls"; +import { BuyLicence } from "../licensing/BuyLicence"; export function LicenceSection({ state }: { state: AppState }) { const [updated, setUpdated] = useState(null); @@ -73,7 +74,7 @@ export function LicenceSection({ state }: { state: AppState }) {

{view.status === "licensed" ? "Use a different key" : "Activate your licence"}

-

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

+

A licence covers one computer and works offline, without a Switchify account.

@@ -87,6 +88,7 @@ export function LicenceSection({ state }: { state: AppState }) {

Have your key saved in a text file?

+ {view.status !== "licensed" && }
{ const file = e.target.files?.[0]; diff --git a/src/ui/app.css b/src/ui/app.css index dd3789b3..c8b30ea8 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -197,6 +197,13 @@ main { min-width: 0; } .licence-banner-hint { margin-top: 4px; color: var(--muted); } .licence-banner[data-locked="true"] { border-left: 4px solid var(--status-attention); background: var(--status-attention-soft); } .licence-banner[data-locked="true"] > svg { color: var(--status-attention); } +.licence-banner > .licence-buy { margin-left: auto; } +.licence-buy { display: grid; justify-items: start; gap: 4px; } +.licence-buy > .licence-supporting { font-size: 14px; } +@media (max-width: 1000px) { + .licence-banner { flex-wrap: wrap; } + .licence-banner > .licence-buy { flex-basis: 100%; margin-left: 40px; } +} .view { width: min(100%, 1760px); margin: 0 auto; padding: 32px clamp(32px, 3vw, 56px); container: page / inline-size; } .page-header, .section-header { display: flex; align-items: center; justify-content: space-between; gap: 16px 24px; } .page-header { margin-bottom: 24px; }