diff --git a/src/App.test.tsx b/src/App.test.tsx
index 99d9b376..d60c9219 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -2,14 +2,10 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { api, browserState } from "./api";
-import type { AppSettings, BluetoothState, SwitchProfile } from "./types";
+import type { BluetoothState, SwitchProfile } from "./types";
const defaultBrowserSettings = structuredClone(browserState.settings);
-function stateWithSettings(settings: AppSettings) {
- return { ...structuredClone(browserState), settings: structuredClone(settings) };
-}
-
describe("Switchify PC shell", () => {
beforeEach(() => {
browserState.settings = structuredClone(defaultBrowserSettings);
@@ -110,52 +106,6 @@ describe("Switchify PC shell", () => {
expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument();
});
- it("shows update progress and exposes cancellation in Settings", async () => {
- browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null };
- const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState));
- render( );
- await screen.findByRole("heading", { name: "Switchify PC" });
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
-
- expect(screen.getByText("Downloading Switchify PC 1.0.0-beta.2…")).toBeInTheDocument();
- expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50");
- expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%");
- fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
- expect(cancel).toHaveBeenCalledOnce();
- });
-
- it("offers the correct retry action after a failure", async () => {
- browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" };
- const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState));
- render( );
- await screen.findByRole("heading", { name: "Switchify PC" });
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- expect(screen.getByRole("alert")).toHaveTextContent("Download failed");
- fireEvent.click(screen.getByRole("button", { name: "Retry" }));
- expect(download).toHaveBeenCalledOnce();
- });
-
- it("offers installation and restart when a download is ready", async () => {
- browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null };
- const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState));
- render( );
- await screen.findByRole("heading", { name: "Switchify PC" });
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- fireEvent.click(screen.getByRole("button", { name: "Install and restart" }));
- expect(install).toHaveBeenCalledOnce();
- });
-
- it("retries a cancelled download from the beginning", async () => {
- browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" };
- const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState));
- render( );
- await screen.findByRole("heading", { name: "Switchify PC" });
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready.");
- fireEvent.click(screen.getByRole("button", { name: "Retry download" }));
- expect(download).toHaveBeenCalledOnce();
- });
-
it("routes tray navigation without discarding a dirty profile silently", async () => {
let navigate: ((target: "home" | "settings" | "profiles") => void) | undefined;
vi.spyOn(api, "onNavigateRequested").mockImplementation(async (handler) => {
@@ -203,28 +153,6 @@ describe("Switchify PC shell", () => {
expect(stop).toHaveBeenCalledOnce();
});
- it("reports manual update-check failures in Settings", async () => {
- const checkForUpdates = vi.spyOn(api, "checkForUpdates").mockRejectedValue(new Error("Update service unavailable"));
- render( );
- await screen.findByRole("heading", { name: "Switchify PC" });
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- fireEvent.click(screen.getByRole("button", { name: "Check for updates" }));
- expect(await screen.findByRole("alert")).toHaveTextContent("Update service unavailable");
- checkForUpdates.mockRestore();
- });
-
- it.each([
- ["checking", "Checking for updates…", "Checking"],
- ["current", "Switchify PC is up to date.", "Check for updates"],
- ] as const)("keeps the %s update state visible in Settings", async (status, description, action) => {
- browserState.updater = { status, version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- expect(screen.getByText(description)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: action })).toBeInTheDocument();
- });
-
it.each([
["available", "Switchify PC 1.0.0-beta.2 is available.", "View update"],
["downloading", "Downloading Switchify PC 1.0.0-beta.2 — 25%", "View progress"],
@@ -393,236 +321,6 @@ describe("Switchify PC shell", () => {
expect(screen.getByRole("heading", { name: "Waiting for an Android device" })).toBeInTheDocument();
});
- it("exposes key repeat settings and disables them with the toggle", async () => {
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" });
- expect(toggle).toBeChecked();
-
- const delay = screen.getByRole("group", { name: "Delay before repeating" });
- expect(delay).not.toBeDisabled();
- for (const label of ["None", "Short", "Medium", "Long"]) {
- expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument();
- }
- expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true");
-
- const interval = screen.getByRole("group", { name: "Key interval" });
- expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true");
- fireEvent.click(within(interval).getByRole("button", { name: "0.5s" }));
- expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true");
-
- fireEvent.click(within(delay).getByRole("button", { name: "None" }));
- expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true");
-
- // Turning the feature off must disable its cadence controls, matching the
- // mouse repeat and dwell blocks.
- fireEvent.click(toggle);
- expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled();
- expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled();
- });
-
- it("opens settings with accessible native controls", async () => {
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
- expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument();
- expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked();
- expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled();
- expect(screen.getAllByRole("button", { name: "Medium" })[0]).toHaveAttribute("aria-pressed", "true");
- fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
- expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByText("2.5")).toBeInTheDocument();
- fireEvent.change(screen.getByRole("combobox", { name: "Exact pointer speed" }), { target: { value: "125" } });
- expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("125");
- expect(screen.getByText("5.5")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" }));
- expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled();
- const dwell = screen.getByRole("checkbox", { name: "Dwell to click" });
- expect(dwell).not.toBeChecked();
- expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled();
- fireEvent.click(dwell);
- const dwellDelay = screen.getByRole("group", { name: "Dwell delay" });
- expect(dwellDelay).not.toBeDisabled();
- expect(within(dwellDelay).getAllByRole("button")).toHaveLength(10);
- for (const label of ["0.5s", "1s", "1.5s", "2s", "3s", "4s", "5s", "6s", "7s", "8s"]) {
- expect(within(dwellDelay).getByRole("button", { name: label })).toBeInTheDocument();
- }
- expect(within(dwellDelay).getByRole("button", { name: "1s" })).toHaveAttribute("aria-pressed", "true");
- fireEvent.click(within(dwellDelay).getByRole("button", { name: "1.5s" }));
- expect(within(dwellDelay).getByRole("button", { name: "1.5s" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByText(/After Android pointer movement stops/)).toBeInTheDocument();
- expect(screen.getByRole("checkbox", { name: "Show cursor overlay" })).toBeChecked();
- expect(screen.getByRole("button", { name: "While controlling" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByText(/On input hides shortly/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "On input" }));
- expect(screen.getByRole("button", { name: "On input" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getAllByRole("button", { name: "Medium" }).at(-1)).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("radio", { name: "Red" })).toBeChecked();
- fireEvent.click(screen.getByRole("checkbox", { name: "Show crosshairs" }));
- expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeChecked();
- fireEvent.click(screen.getByRole("checkbox", { name: "Show cursor overlay" }));
- expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeDisabled();
- });
-
- it("automatically saves a settings change", async () => {
- const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" }));
-
- await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ shareDiagnostics: true })));
- expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked();
- });
-
- it("persists dwell enablement and delay through automatic settings saves", async () => {
- const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" }));
- await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 1000 })));
- const dwellDelay = screen.getByRole("group", { name: "Dwell delay" });
- fireEvent.click(within(dwellDelay).getByRole("button", { name: "8s" }));
- await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 8000 })));
- expect(within(dwellDelay).getByRole("button", { name: "8s" })).toHaveAttribute("aria-pressed", "true");
- });
-
- it("restores confirmed dwell settings when automatic saving fails", async () => {
- vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable"));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable");
- expect(screen.getByRole("checkbox", { name: "Dwell to click" })).not.toBeChecked();
- expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled();
- });
-
- it("explains telemetry consent and links to the privacy policy", async () => {
- const first = render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
- expect(screen.getByText(/Nothing is sent unless you choose Share diagnostics/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Don't share" }));
- expect(await screen.findByText("Opted out. No diagnostic reports are stored or sent.")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Privacy policy" })).toHaveAttribute("href", "https://switchifyapp.com/privacy");
-
- first.unmount();
- browserState.telemetry = { consent: "undecided", available: false };
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
- expect(await screen.findByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeDisabled();
- expect(screen.getByText("Diagnostic reporting is unavailable in this build.")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Share diagnostics" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Don't share" })).toBeEnabled();
- });
-
- it("serializes rapid settings changes without applying a stale response", async () => {
- const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = [];
- vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => {
- saves.push({ settings: structuredClone(settings), resolve });
- }));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
- fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" }));
-
- expect(saves).toHaveLength(1);
- expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true");
-
- await act(async () => {
- saves[0].resolve(stateWithSettings(saves[0].settings));
- });
- await waitFor(() => expect(saves).toHaveLength(2));
- expect(saves[1].settings.pointerScalePercent).toBe(75);
- expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true");
-
- await act(async () => {
- saves[1].resolve(stateWithSettings(saves[1].settings));
- });
- });
-
- it("preserves newer runtime state when a settings save completes", async () => {
- let stateHandler: ((state: typeof browserState) => void) | undefined;
- let finishSave: ((state: typeof browserState) => void) | undefined;
- vi.spyOn(api, "onState").mockImplementation(async (handler) => {
- stateHandler = handler;
- return () => undefined;
- });
- vi.spyOn(api, "saveSettings").mockImplementation(() => new Promise((resolve) => {
- finishSave = resolve;
- }));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
- const runtimeState = {
- ...structuredClone(browserState),
- bluetooth: "connected" as const,
- connectedDeviceName: "Newer connected device",
- };
- act(() => stateHandler?.(runtimeState));
- await act(async () => {
- finishSave?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 50 }));
- });
-
- fireEvent.click(screen.getByRole("button", { name: "Home" }));
- expect(screen.getByText("Newer connected device")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument();
- });
-
- it("rebases local edits on newer backend settings", async () => {
- let stateHandler: ((state: typeof browserState) => void) | undefined;
- const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = [];
- vi.spyOn(api, "onState").mockImplementation(async (handler) => {
- stateHandler = handler;
- return () => undefined;
- });
- vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => {
- saves.push({ settings: structuredClone(settings), resolve });
- }));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" }));
- fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
- act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 })));
-
- expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked();
- expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150");
-
- await act(async () => {
- saves[0].resolve(stateWithSettings(saves[0].settings));
- });
- await waitFor(() => expect(saves).toHaveLength(2));
- expect(saves[1].settings).toEqual(expect.objectContaining({
- pointerScalePercent: 150,
- shareDiagnostics: true,
- }));
-
- await act(async () => {
- saves[1].resolve(stateWithSettings(saves[1].settings));
- });
- });
-
- it("restores confirmed settings when automatic saving fails", async () => {
- vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable"));
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
-
- const startup = screen.getByRole("checkbox", { name: "Start with system" });
- fireEvent.click(startup);
- expect(startup).toBeChecked();
-
- expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable");
- expect(startup).not.toBeChecked();
- });
-
it("creates a profile and records a desired key", async () => {
let saved: SwitchProfile | undefined;
vi.spyOn(api, "saveProfile").mockImplementation(async (profile) => {
@@ -794,29 +492,6 @@ describe("Switchify PC shell", () => {
await waitFor(() => expect(updates).toHaveFocus());
});
- it("focuses Updates when the update banner is selected from Settings", async () => {
- browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
- render( );
- fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
- const updates = screen.getByRole("region", { name: "Updates" });
- expect(updates).not.toHaveFocus();
- fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" }));
- await waitFor(() => expect(updates).toHaveFocus());
- });
-
- it("does not replay a consumed Updates focus request on normal Settings navigation", async () => {
- browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
- render( );
- const banner = await screen.findByRole("status", { name: "Application update" });
- fireEvent.click(within(banner).getByRole("button", { name: "View update" }));
- const updates = await screen.findByRole("region", { name: "Updates" });
- await waitFor(() => expect(updates).toHaveFocus());
-
- fireEvent.click(screen.getByRole("button", { name: "Home" }));
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- expect(screen.getByRole("region", { name: "Updates" })).not.toHaveFocus();
- });
-
it("guides macOS users through required and stale accessibility entries", async () => {
const originalPlatform = browserState.capabilities.platform;
browserState.capabilities.platform = "macos";
diff --git a/src/App.tsx b/src/App.tsx
index fcad1e39..87d1d359 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Accessibility, Bluetooth, CheckCircle2, ChevronRight, CircleHelp, Download,
Copy, Home, Keyboard, Plus, Power, Radio, RefreshCw, Save, Settings,
@@ -6,6 +6,9 @@ import {
} from "lucide-react";
import { api, type ProfileExitAction } from "./api";
import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types";
+import { applyLocalSettings, changedSettingKeys } from "./settings/diff";
+import { SettingsView } from "./settings/SettingsView";
+import { updateProgress, type UpdateAction } from "./settings/UpdatesSection";
type View = "home" | "devices" | "profiles" | "settings" | "support";
@@ -38,10 +41,6 @@ function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) {
return {children} ;
}
-function Toggle({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (next: boolean) => void }) {
- return {label} onChange(event.target.checked)} /> ;
-}
-
function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) {
if (state.accessibility === "granted") return
Ready
;
if (state.accessibility === "unavailable") return Unavailable on this system
;
@@ -284,84 +283,6 @@ function ProfilesView({ profiles, platform, saveProfile, deleteProfile, onDirtyC
;
}
-function SettingGroup({ title, description, children, id, sectionRef, focusable = false }: { title: string; description: string; children: ReactNode; id?: string; sectionRef?: Ref; focusable?: boolean }) {
- const headingId = id ? `${id}-heading` : undefined;
- return ;
-}
-
-const pointerSpeedOptions = [5, 25, 50, 75, 100] as const;
-const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5);
-const repeatIntervalOptions = [100, 250, 500, 1000] as const;
-const keyRepeatDelayOptions = [
- { value: 0, label: "None" },
- { value: 250, label: "Short" },
- { value: 500, label: "Medium" },
- { value: 1000, label: "Long" },
-] as const;
-const accelerationOptions = [
- { value: 0, label: "Off" },
- { value: 500, label: "Short" },
- { value: 1000, label: "Medium" },
- { value: 2000, label: "Long" },
-] as const;
-const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const;
-
-function movementValue(base: number, scale: number) {
- const value = Math.min(50, Math.max(1, Math.round((base * scale / 100) * 2) / 2));
- return Number.isInteger(value) ? String(value) : value.toFixed(1);
-}
-
-const changedSettingKeys = (previous: AppSettings, next: AppSettings) =>
- (Object.keys(next) as Array).filter((key) => previous[key] !== next[key]);
-
-function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set) {
- const merged = { ...base };
- for (const key of keys) Object.assign(merged, { [key]: local[key] });
- return merged;
-}
-
-type UpdateAction = "check" | "download" | "install";
-
-function updateProgress(update: UpdateState) {
- if (update.totalBytes && update.totalBytes > 0) {
- return `${Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes))}%`;
- }
- return `${update.downloadedBytes.toLocaleString()} bytes`;
-}
-
-function updateDescription(update: UpdateState) {
- switch (update.status) {
- case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured.";
- case "idle": return "Automatic update checks are enabled.";
- case "checking": return "Checking for updates…";
- case "available": return `Switchify PC ${update.version} is available.`;
- case "downloading": return `Downloading Switchify PC ${update.version}…`;
- case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`;
- case "applying": return `Installing Switchify PC ${update.version}…`;
- case "current": return "Switchify PC is up to date.";
- case "failed": return update.error ?? "The update operation failed.";
- case "cancelled": return "Download cancelled. You can retry when ready.";
- }
-}
-
-function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) {
- const action = update.status === "available" || update.status === "cancelled" ? "download"
- : update.status === "readyToInstall" ? "install"
- : update.status === "failed" ? update.retryAction
- : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null;
- const label = update.status === "failed" ? "Retry"
- : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download")
- : action === "install" ? "Install and restart" : "Check for updates";
- return
-
{updateDescription(update)}
- {update.status === "downloading" && <>
-
-
{updateProgress(update)}
- >}
-
{action && run(action)}>{action === "download" && }{action === "check" && }{label} }{update.status === "downloading" && Cancel }{(update.status === "checking" || update.status === "applying") && {update.status === "checking" ? "Checking" : "Installing"} }
-
;
-}
-
function UpdateBanner({ update, openUpdates }: { update: UpdateState; openUpdates: () => void }) {
if (update.status !== "available" && update.status !== "downloading" && update.status !== "readyToInstall") return null;
const message = update.status === "available"
@@ -376,81 +297,6 @@ function UpdateBanner({ update, openUpdates }: { update: UpdateState; openUpdate
;
}
-function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void }) {
- const updatesRef = useRef(null);
- useEffect(() => {
- if (!focusUpdates) return;
- updatesRef.current?.scrollIntoView?.({ block: "start" });
- updatesRef.current?.focus({ preventScroll: true });
- onUpdatesFocused();
- }, [focusUpdates, onUpdatesFocused]);
- const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value });
- return
-
update("startWithSystem", value)} />
-
- Pointer speed {settings.pointerScalePercent}%
- {pointerSpeedOptions.map((value) => update("pointerScalePercent", value)}>{value}% )}
-
Exact speed update("pointerScalePercent", Number(event.target.value))}>
- {pointerSpeedValues.map((value) => {value}% )}
-
- {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label} {movementValue(base, settings.pointerScalePercent)}
)}
-
-
-
update("mouseRepeatEnabled", value)} />
-
-
Movement interval
- {repeatIntervalOptions.map((value) => update("moveRepeatIntervalMs", value)}>{value / 1000}s )}
-
-
Movement acceleration
- {accelerationOptions.map(({ value, label }) => update("mouseRepeatAccelerationDurationMs", value)}>{label} )}
-
-
Scroll interval
- {repeatIntervalOptions.map((value) => update("scrollRepeatIntervalMs", value)}>{value / 1000}s )}
-
-
-
-
-
update("keyRepeatEnabled", value)} />
-
-
Delay before repeating
- {keyRepeatDelayOptions.map(({ value, label }) => update("keyRepeatInitialDelayMs", value)}>{label} )}
-
-
Key interval
- {repeatIntervalOptions.map((value) => update("keyRepeatIntervalMs", value)}>{value / 1000}s )}
-
-
Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.
-
-
-
-
update("dwellClickEnabled", value)} />
-
-
Dwell delay
- {dwellDelayOptions.map((value) => update("dwellClickDelayMs", value)}>{value / 1000}s )}
-
-
After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.
-
-
- {state.capabilities.cursorOverlay && <>
- update("cursorOverlayEnabled", value)} />
-
-
Overlay visibility
- {(["onInput", "whileControlling"] as const).map((value) => update("cursorOverlayVisibility", value)}>{value === "onInput" ? "On input" : "While controlling"} )}
-
On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.
-
Overlay size
- {(["small", "medium", "large"] as const).map((value) => update("cursorOverlaySize", value)}>{value[0].toUpperCase() + value.slice(1)} )}
-
-
Overlay color
- {(["red", "green", "blue", "yellow", "white"] as const).map((value) => update("cursorOverlayColor", value)} />{value[0].toUpperCase() + value.slice(1)} )}
-
-
update("cursorCrosshairs", value)} />
-
- >}
-
-
update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" && chooseTelemetry(true)}>Share diagnostics chooseTelemetry(false)}>Don't share
}{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy
-
-
;
-}
-
function SupportView({ state, busy, perform, openSetup, openUpdates }: { state: AppState; busy: boolean; perform: (operation: () => Promise) => void; openSetup: () => void; openUpdates: () => void }) {
const [tab, setTab] = useState<"setup" | "troubleshooting">("setup");
const bluetoothReady = state.bluetooth === "advertising" || state.bluetooth === "connected";
diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx
new file mode 100644
index 00000000..2cd9f49f
--- /dev/null
+++ b/src/settings/SettingsView.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useRef } from "react";
+import { Settings } from "lucide-react";
+import type { AppSettings, AppState } from "../types";
+import {
+ SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions,
+ movementValue, pointerSpeedOptions, pointerSpeedValues, repeatIntervalOptions,
+} from "./controls";
+import { UpdateControls, type UpdateAction } from "./UpdatesSection";
+
+export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void }) {
+ const updatesRef = useRef(null);
+ useEffect(() => {
+ if (!focusUpdates) return;
+ updatesRef.current?.scrollIntoView?.({ block: "start" });
+ updatesRef.current?.focus({ preventScroll: true });
+ onUpdatesFocused();
+ }, [focusUpdates, onUpdatesFocused]);
+ const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value });
+ return
+
update("startWithSystem", value)} />
+
+ Pointer speed {settings.pointerScalePercent}%
+ {pointerSpeedOptions.map((value) => update("pointerScalePercent", value)}>{value}% )}
+
Exact speed update("pointerScalePercent", Number(event.target.value))}>
+ {pointerSpeedValues.map((value) => {value}% )}
+
+ {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label} {movementValue(base, settings.pointerScalePercent)}
)}
+
+
+
update("mouseRepeatEnabled", value)} />
+
+
Movement interval
+ {repeatIntervalOptions.map((value) => update("moveRepeatIntervalMs", value)}>{value / 1000}s )}
+
+
Movement acceleration
+ {accelerationOptions.map(({ value, label }) => update("mouseRepeatAccelerationDurationMs", value)}>{label} )}
+
+
Scroll interval
+ {repeatIntervalOptions.map((value) => update("scrollRepeatIntervalMs", value)}>{value / 1000}s )}
+
+
+
+
+
update("keyRepeatEnabled", value)} />
+
+
Delay before repeating
+ {keyRepeatDelayOptions.map(({ value, label }) => update("keyRepeatInitialDelayMs", value)}>{label} )}
+
+
Key interval
+ {repeatIntervalOptions.map((value) => update("keyRepeatIntervalMs", value)}>{value / 1000}s )}
+
+
Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.
+
+
+
+
update("dwellClickEnabled", value)} />
+
+
Dwell delay
+ {dwellDelayOptions.map((value) => update("dwellClickDelayMs", value)}>{value / 1000}s )}
+
+
After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.
+
+
+ {state.capabilities.cursorOverlay && <>
+ update("cursorOverlayEnabled", value)} />
+
+
Overlay visibility
+ {(["onInput", "whileControlling"] as const).map((value) => update("cursorOverlayVisibility", value)}>{value === "onInput" ? "On input" : "While controlling"} )}
+
On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.
+
Overlay size
+ {(["small", "medium", "large"] as const).map((value) => update("cursorOverlaySize", value)}>{value[0].toUpperCase() + value.slice(1)} )}
+
+
Overlay color
+ {(["red", "green", "blue", "yellow", "white"] as const).map((value) => update("cursorOverlayColor", value)} />{value[0].toUpperCase() + value.slice(1)} )}
+
+
update("cursorCrosshairs", value)} />
+
+ >}
+
+
update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" && chooseTelemetry(true)}>Share diagnostics chooseTelemetry(false)}>Don't share
}{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy
+
+
;
+}
diff --git a/src/settings/UpdatesSection.tsx b/src/settings/UpdatesSection.tsx
new file mode 100644
index 00000000..eedd21e3
--- /dev/null
+++ b/src/settings/UpdatesSection.tsx
@@ -0,0 +1,44 @@
+import { Download, RefreshCw, X } from "lucide-react";
+import type { UpdateState } from "../types";
+
+export type UpdateAction = "check" | "download" | "install";
+
+export function updateProgress(update: UpdateState) {
+ if (update.totalBytes && update.totalBytes > 0) {
+ return `${Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes))}%`;
+ }
+ return `${update.downloadedBytes.toLocaleString()} bytes`;
+}
+
+export function updateDescription(update: UpdateState) {
+ switch (update.status) {
+ case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured.";
+ case "idle": return "Automatic update checks are enabled.";
+ case "checking": return "Checking for updates…";
+ case "available": return `Switchify PC ${update.version} is available.`;
+ case "downloading": return `Downloading Switchify PC ${update.version}…`;
+ case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`;
+ case "applying": return `Installing Switchify PC ${update.version}…`;
+ case "current": return "Switchify PC is up to date.";
+ case "failed": return update.error ?? "The update operation failed.";
+ case "cancelled": return "Download cancelled. You can retry when ready.";
+ }
+}
+
+export function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) {
+ const action = update.status === "available" || update.status === "cancelled" ? "download"
+ : update.status === "readyToInstall" ? "install"
+ : update.status === "failed" ? update.retryAction
+ : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null;
+ const label = update.status === "failed" ? "Retry"
+ : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download")
+ : action === "install" ? "Install and restart" : "Check for updates";
+ return
+
{updateDescription(update)}
+ {update.status === "downloading" && <>
+
+
{updateProgress(update)}
+ >}
+
{action && run(action)}>{action === "download" && }{action === "check" && }{label} }{update.status === "downloading" && Cancel }{(update.status === "checking" || update.status === "applying") && {update.status === "checking" ? "Checking" : "Installing"} }
+
;
+}
diff --git a/src/settings/controls.tsx b/src/settings/controls.tsx
new file mode 100644
index 00000000..905c0c35
--- /dev/null
+++ b/src/settings/controls.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode, Ref } from "react";
+
+export function Toggle({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (next: boolean) => void }) {
+ return {label} onChange(event.target.checked)} /> ;
+}
+
+export function SettingGroup({ title, description, children, id, sectionRef, focusable = false }: { title: string; description: string; children: ReactNode; id?: string; sectionRef?: Ref; focusable?: boolean }) {
+ const headingId = id ? `${id}-heading` : undefined;
+ return ;
+}
+
+export const pointerSpeedOptions = [5, 25, 50, 75, 100] as const;
+export const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5);
+export const repeatIntervalOptions = [100, 250, 500, 1000] as const;
+export const keyRepeatDelayOptions = [
+ { value: 0, label: "None" },
+ { value: 250, label: "Short" },
+ { value: 500, label: "Medium" },
+ { value: 1000, label: "Long" },
+] as const;
+export const accelerationOptions = [
+ { value: 0, label: "Off" },
+ { value: 500, label: "Short" },
+ { value: 1000, label: "Medium" },
+ { value: 2000, label: "Long" },
+] as const;
+export const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const;
+
+export function movementValue(base: number, scale: number) {
+ const value = Math.min(50, Math.max(1, Math.round((base * scale / 100) * 2) / 2));
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
+}
diff --git a/src/settings/diff.ts b/src/settings/diff.ts
new file mode 100644
index 00000000..f0bb7a0c
--- /dev/null
+++ b/src/settings/diff.ts
@@ -0,0 +1,10 @@
+import type { AppSettings } from "../types";
+
+export const changedSettingKeys = (previous: AppSettings, next: AppSettings) =>
+ (Object.keys(next) as Array).filter((key) => previous[key] !== next[key]);
+
+export function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set) {
+ const merged = { ...base };
+ for (const key of keys) Object.assign(merged, { [key]: local[key] });
+ return merged;
+}
diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx
new file mode 100644
index 00000000..65ca49a4
--- /dev/null
+++ b/src/settings/settings.test.tsx
@@ -0,0 +1,352 @@
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { App } from "../App";
+import { api, browserState } from "../api";
+import type { AppSettings } from "../types";
+
+const defaultBrowserSettings = structuredClone(browserState.settings);
+
+function stateWithSettings(settings: AppSettings) {
+ return { ...structuredClone(browserState), settings: structuredClone(settings) };
+}
+
+describe("Switchify PC settings", () => {
+ beforeEach(() => {
+ browserState.settings = structuredClone(defaultBrowserSettings);
+ browserState.bluetooth = "initializing";
+ browserState.pendingPairings = [];
+ browserState.pairedDevices = [];
+ browserState.connectedDeviceName = null;
+ browserState.lastActivity = null;
+ browserState.diagnostics = { recentBluetooth: [], lastDisconnect: null, recentErrors: [] };
+ browserState.telemetry = { consent: "undecided", available: true };
+ browserState.updater = { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
+ browserState.setup = { shown: true, completed: false, autoOpenEligible: false };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("shows update progress and exposes cancellation in Settings", async () => {
+ browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null };
+ const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState));
+ render( );
+ await screen.findByRole("heading", { name: "Switchify PC" });
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+
+ expect(screen.getByText("Downloading Switchify PC 1.0.0-beta.2…")).toBeInTheDocument();
+ expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50");
+ expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%");
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+
+ it("offers the correct retry action after a failure", async () => {
+ browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" };
+ const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState));
+ render( );
+ await screen.findByRole("heading", { name: "Switchify PC" });
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ expect(screen.getByRole("alert")).toHaveTextContent("Download failed");
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(download).toHaveBeenCalledOnce();
+ });
+
+ it("offers installation and restart when a download is ready", async () => {
+ browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null };
+ const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState));
+ render( );
+ await screen.findByRole("heading", { name: "Switchify PC" });
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ fireEvent.click(screen.getByRole("button", { name: "Install and restart" }));
+ expect(install).toHaveBeenCalledOnce();
+ });
+
+ it("retries a cancelled download from the beginning", async () => {
+ browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" };
+ const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState));
+ render( );
+ await screen.findByRole("heading", { name: "Switchify PC" });
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready.");
+ fireEvent.click(screen.getByRole("button", { name: "Retry download" }));
+ expect(download).toHaveBeenCalledOnce();
+ });
+
+ it("reports manual update-check failures in Settings", async () => {
+ const checkForUpdates = vi.spyOn(api, "checkForUpdates").mockRejectedValue(new Error("Update service unavailable"));
+ render( );
+ await screen.findByRole("heading", { name: "Switchify PC" });
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ fireEvent.click(screen.getByRole("button", { name: "Check for updates" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Update service unavailable");
+ checkForUpdates.mockRestore();
+ });
+
+ it.each([
+ ["checking", "Checking for updates…", "Checking"],
+ ["current", "Switchify PC is up to date.", "Check for updates"],
+ ] as const)("keeps the %s update state visible in Settings", async (status, description, action) => {
+ browserState.updater = { status, version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ expect(screen.getByText(description)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: action })).toBeInTheDocument();
+ });
+
+ it("exposes key repeat settings and disables them with the toggle", async () => {
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" });
+ expect(toggle).toBeChecked();
+
+ const delay = screen.getByRole("group", { name: "Delay before repeating" });
+ expect(delay).not.toBeDisabled();
+ for (const label of ["None", "Short", "Medium", "Long"]) {
+ expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument();
+ }
+ expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true");
+
+ const interval = screen.getByRole("group", { name: "Key interval" });
+ expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(within(interval).getByRole("button", { name: "0.5s" }));
+ expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true");
+
+ fireEvent.click(within(delay).getByRole("button", { name: "None" }));
+ expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true");
+
+ // Turning the feature off must disable its cadence controls, matching the
+ // mouse repeat and dwell blocks.
+ fireEvent.click(toggle);
+ expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled();
+ expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled();
+ });
+
+ it("opens settings with accessible native controls", async () => {
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+ expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked();
+ expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled();
+ expect(screen.getAllByRole("button", { name: "Medium" })[0]).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
+ expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText("2.5")).toBeInTheDocument();
+ fireEvent.change(screen.getByRole("combobox", { name: "Exact pointer speed" }), { target: { value: "125" } });
+ expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("125");
+ expect(screen.getByText("5.5")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" }));
+ expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled();
+ const dwell = screen.getByRole("checkbox", { name: "Dwell to click" });
+ expect(dwell).not.toBeChecked();
+ expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled();
+ fireEvent.click(dwell);
+ const dwellDelay = screen.getByRole("group", { name: "Dwell delay" });
+ expect(dwellDelay).not.toBeDisabled();
+ expect(within(dwellDelay).getAllByRole("button")).toHaveLength(10);
+ for (const label of ["0.5s", "1s", "1.5s", "2s", "3s", "4s", "5s", "6s", "7s", "8s"]) {
+ expect(within(dwellDelay).getByRole("button", { name: label })).toBeInTheDocument();
+ }
+ expect(within(dwellDelay).getByRole("button", { name: "1s" })).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(within(dwellDelay).getByRole("button", { name: "1.5s" }));
+ expect(within(dwellDelay).getByRole("button", { name: "1.5s" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText(/After Android pointer movement stops/)).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Show cursor overlay" })).toBeChecked();
+ expect(screen.getByRole("button", { name: "While controlling" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText(/On input hides shortly/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "On input" }));
+ expect(screen.getByRole("button", { name: "On input" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getAllByRole("button", { name: "Medium" }).at(-1)).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("radio", { name: "Red" })).toBeChecked();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Show crosshairs" }));
+ expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeChecked();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Show cursor overlay" }));
+ expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeDisabled();
+ });
+
+ it("automatically saves a settings change", async () => {
+ const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" }));
+
+ await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ shareDiagnostics: true })));
+ expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked();
+ });
+
+ it("persists dwell enablement and delay through automatic settings saves", async () => {
+ const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" }));
+ await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 1000 })));
+ const dwellDelay = screen.getByRole("group", { name: "Dwell delay" });
+ fireEvent.click(within(dwellDelay).getByRole("button", { name: "8s" }));
+ await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 8000 })));
+ expect(within(dwellDelay).getByRole("button", { name: "8s" })).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("restores confirmed dwell settings when automatic saving fails", async () => {
+ vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable"));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable");
+ expect(screen.getByRole("checkbox", { name: "Dwell to click" })).not.toBeChecked();
+ expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled();
+ });
+
+ it("explains telemetry consent and links to the privacy policy", async () => {
+ const first = render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+ expect(screen.getByText(/Nothing is sent unless you choose Share diagnostics/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Don't share" }));
+ expect(await screen.findByText("Opted out. No diagnostic reports are stored or sent.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Privacy policy" })).toHaveAttribute("href", "https://switchifyapp.com/privacy");
+
+ first.unmount();
+ browserState.telemetry = { consent: "undecided", available: false };
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+ expect(await screen.findByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeDisabled();
+ expect(screen.getByText("Diagnostic reporting is unavailable in this build.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Share diagnostics" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Don't share" })).toBeEnabled();
+ });
+
+ it("serializes rapid settings changes without applying a stale response", async () => {
+ const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = [];
+ vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => {
+ saves.push({ settings: structuredClone(settings), resolve });
+ }));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
+ fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" }));
+
+ expect(saves).toHaveLength(1);
+ expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true");
+
+ await act(async () => {
+ saves[0].resolve(stateWithSettings(saves[0].settings));
+ });
+ await waitFor(() => expect(saves).toHaveLength(2));
+ expect(saves[1].settings.pointerScalePercent).toBe(75);
+ expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true");
+
+ await act(async () => {
+ saves[1].resolve(stateWithSettings(saves[1].settings));
+ });
+ });
+
+ it("preserves newer runtime state when a settings save completes", async () => {
+ let stateHandler: ((state: typeof browserState) => void) | undefined;
+ let finishSave: ((state: typeof browserState) => void) | undefined;
+ vi.spyOn(api, "onState").mockImplementation(async (handler) => {
+ stateHandler = handler;
+ return () => undefined;
+ });
+ vi.spyOn(api, "saveSettings").mockImplementation(() => new Promise((resolve) => {
+ finishSave = resolve;
+ }));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
+ const runtimeState = {
+ ...structuredClone(browserState),
+ bluetooth: "connected" as const,
+ connectedDeviceName: "Newer connected device",
+ };
+ act(() => stateHandler?.(runtimeState));
+ await act(async () => {
+ finishSave?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 50 }));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Home" }));
+ expect(screen.getByText("Newer connected device")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument();
+ });
+
+ it("rebases local edits on newer backend settings", async () => {
+ let stateHandler: ((state: typeof browserState) => void) | undefined;
+ const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = [];
+ vi.spyOn(api, "onState").mockImplementation(async (handler) => {
+ stateHandler = handler;
+ return () => undefined;
+ });
+ vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => {
+ saves.push({ settings: structuredClone(settings), resolve });
+ }));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" }));
+ fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" }));
+ act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 })));
+
+ expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked();
+ expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150");
+
+ await act(async () => {
+ saves[0].resolve(stateWithSettings(saves[0].settings));
+ });
+ await waitFor(() => expect(saves).toHaveLength(2));
+ expect(saves[1].settings).toEqual(expect.objectContaining({
+ pointerScalePercent: 150,
+ shareDiagnostics: true,
+ }));
+
+ await act(async () => {
+ saves[1].resolve(stateWithSettings(saves[1].settings));
+ });
+ });
+
+ it("restores confirmed settings when automatic saving fails", async () => {
+ vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable"));
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+
+ const startup = screen.getByRole("checkbox", { name: "Start with system" });
+ fireEvent.click(startup);
+ expect(startup).toBeChecked();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable");
+ expect(startup).not.toBeChecked();
+ });
+
+ it("focuses Updates when the update banner is selected from Settings", async () => {
+ browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
+ const updates = screen.getByRole("region", { name: "Updates" });
+ expect(updates).not.toHaveFocus();
+ fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" }));
+ await waitFor(() => expect(updates).toHaveFocus());
+ });
+
+ it("does not replay a consumed Updates focus request on normal Settings navigation", async () => {
+ browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null };
+ render( );
+ const banner = await screen.findByRole("status", { name: "Application update" });
+ fireEvent.click(within(banner).getByRole("button", { name: "View update" }));
+ const updates = await screen.findByRole("region", { name: "Updates" });
+ await waitFor(() => expect(updates).toHaveFocus());
+
+ fireEvent.click(screen.getByRole("button", { name: "Home" }));
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ expect(screen.getByRole("region", { name: "Updates" })).not.toHaveFocus();
+ });
+
+});