Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 9 additions & 4 deletions site/src/content/docs/licence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.

Expand Down
53 changes: 53 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<tauri_plugin_updater::Update>::default())
.manage(PendingProfileExit::default())
Expand Down
31 changes: 30 additions & 1 deletion src-tauri/tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -898,6 +899,7 @@ export function App() {
<p className="licence-banner-message">{state.licence.message}</p>
<p className="licence-banner-hint">See Settings → Licence.</p>
</div>
<BuyLicence />
</div>
</div>}
{error && <div className="error-banner" role="alert">{error}<Button onClick={() => setError(null)}>Dismiss</Button></div>}
Expand Down
26 changes: 26 additions & 0 deletions src/licensing/BuyLicence.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BuyLicence />);
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(<BuyLicence />);
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);
});
27 changes: 27 additions & 0 deletions src/licensing/BuyLicence.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="licence-buy">
<Button className="secondary" disabled={busy} onClick={() => void buy()}>
Buy licence <ExternalLink size={18} aria-hidden="true" />
</Button>
<p className="licence-supporting">Test checkout · no real payment</p>
{failed && <p className="field-error" role="alert">Could not open checkout in your browser. Try again when your browser is available.</p>}
</div>;
}
4 changes: 3 additions & 1 deletion src/settings/LicenceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LicenceView | null>(null);
Expand Down Expand Up @@ -73,7 +74,7 @@ export function LicenceSection({ state }: { state: AppState }) {
<div className="licence-activation">
<div>
<h3>{view.status === "licensed" ? "Use a different key" : "Activate your licence"}</h3>
<p className="licence-supporting">€100 one-time purchase for one computer. A key works offline, without a Switchify account.</p>
<p className="licence-supporting">A licence covers one computer and works offline, without a Switchify account.</p>
</div>
<div className="licence-key-field">
<label htmlFor="licence-key">Licence key</label>
Expand All @@ -87,6 +88,7 @@ export function LicenceSection({ state }: { state: AppState }) {
<p className="licence-supporting">Have your key saved in a text file?</p>
<Button disabled={busy} onClick={() => fileInput.current?.click()}>Import licence file</Button>
</div>
{view.status !== "licensed" && <BuyLicence />}
</div>
<input ref={fileInput} aria-label="Licence file" type="file" accept=".txt,.licence" hidden disabled={busy} onChange={(e) => {
const file = e.target.files?.[0];
Expand Down
7 changes: 7 additions & 0 deletions src/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading