diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 390b04be..876a13e4 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -41,6 +41,7 @@ fn main() { "install_update", "export_diagnostics", "get_account", + "get_account_status", "request_sign_in_code", "verify_sign_in_code", "cancel_sign_in", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 79b678ce..47fca436 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -41,6 +41,7 @@ "allow-install-update", "allow-export-diagnostics", "allow-get-account", + "allow-get-account-status", "allow-request-sign-in-code", "allow-verify-sign-in-code", "allow-cancel-sign-in", diff --git a/src-tauri/permissions/autogenerated/get_account_status.toml b/src-tauri/permissions/autogenerated/get_account_status.toml new file mode 100644 index 00000000..ac2dba83 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_account_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-account-status" +description = "Enables the get_account_status command without any pre-configured scope." +commands.allow = ["get_account_status"] + +[[permission]] +identifier = "deny-get-account-status" +description = "Denies the get_account_status command without any pre-configured scope." +commands.deny = ["get_account_status"] diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 19178599..6f817de8 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -881,7 +881,7 @@ mod tests { // Background calls never re-read the keychain (no prompts)... assert!(account.authorize().await.is_err()); assert!(transport.requests().is_empty()); - // ...but opening the Account tab does. + // ...but opening the Account page does. let view = account.view().await; assert!(view.signed_in && !view.keychain_unavailable); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6fe8195..121347bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -832,7 +832,9 @@ async fn get_account( account: State<'_, account::Account>, ) -> Result { let view = account.view().await; - // Opening the tab can find a session a locked keychain hid at startup. + // Opening the tab can recover a session a locked keychain hid at startup; + // tell the rest of the UI (e.g. the sidebar) too. + account::emit(&app, &view); if view.signed_in { if let Some(sync) = sync_engine(&app) { if sync.view().status == settings_sync::Status::Off { @@ -843,6 +845,15 @@ async fn get_account( Ok(view) } +/// The account as currently known, without re-reading the keychain, for +/// views that load without the user asking (such as the sidebar). +#[tauri::command] +async fn get_account_status( + account: State<'_, account::Account>, +) -> Result { + Ok(account.current_view().await) +} + #[tauri::command] async fn request_sign_in_code( app: AppHandle, @@ -1767,6 +1778,7 @@ pub fn run() { install_update, export_diagnostics, get_account, + get_account_status, request_sign_in_code, verify_sign_in_code, cancel_sign_in, diff --git a/src/App.test.tsx b/src/App.test.tsx index 8c0840ef..32587cea 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,7 +2,7 @@ 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 { BluetoothState, SwitchProfile } from "./types"; +import type { AccountView, BluetoothState, SwitchProfile } from "./types"; import * as switchHooks from "./scanning/useSwitches"; import * as scanHooks from "./scanning/useScanning"; @@ -580,6 +580,102 @@ describe("Switchify PC shell", () => { await waitFor(() => expect(opener).toHaveFocus()); }); + describe("sidebar account", () => { + const signedIn: AccountView = { available: true, signedIn: true, email: "me@example.com", pendingEmail: null, keychainUnavailable: false }; + const signedOut: AccountView = { ...signedIn, signedIn: false, email: null }; + const sidebar = async () => { + await screen.findByRole("navigation"); + return within(document.querySelector("aside") as HTMLElement); + }; + let lastStatus: ReturnType | undefined; + const mockAccount = (view: AccountView) => { + let receive: (next: AccountView) => void = () => undefined; + const status = vi.spyOn(api, "accountStatus").mockResolvedValue(view); + vi.spyOn(api, "account").mockResolvedValue(view); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + lastStatus = status; + vi.spyOn(api, "settingsSync").mockResolvedValue({ status: "upToDate", lastSyncedAt: null, message: null }); + vi.spyOn(api, "onSettingsSync").mockResolvedValue(() => undefined); + return (next: AccountView) => receive(next); + }; + + it("replaces the version with the signed-in account", async () => { + mockAccount(signedIn); + render(); + const entry = await (await sidebar()).findByRole("button", { name: "Account, signed in as me@example.com" }); + expect(entry).toHaveTextContent("Accountme@example.com"); + expect((await sidebar()).queryByText(/^v\d/)).not.toBeInTheDocument(); + }); + + it("offers sign-in when signed out and follows account changes", async () => { + const receive = mockAccount(signedOut); + render(); + expect(await (await sidebar()).findByRole("button", { name: "Sign in to your account" })).toHaveTextContent("Sign in"); + act(() => receive(signedIn)); + expect(await (await sidebar()).findByRole("button", { name: "Account, signed in as me@example.com" })).toBeInTheDocument(); + }); + + it("opens the Account page and marks the entry current", async () => { + mockAccount(signedIn); + render(); + const entry = await (await sidebar()).findByRole("button", { name: /^Account, signed in/ }); + fireEvent.click(entry); + expect(await screen.findByRole("heading", { name: "Account", level: 1 })).toBeInTheDocument(); + expect(await screen.findByText(/Signed in as/)).toBeInTheDocument(); + expect(entry).toHaveAttribute("aria-current", "page"); + expect(screen.queryByRole("tablist", { name: "Settings sections" })).not.toBeInTheDocument(); + }); + + it("reads the account without the keychain-retrying command", async () => { + mockAccount(signedIn); + const retrying = vi.mocked(api.account); + render(); + await (await sidebar()).findByRole("button", { name: /^Account, signed in/ }); + expect(lastStatus).toHaveBeenCalled(); + expect(retrying).not.toHaveBeenCalled(); + }); + + it("stays neutral while the keychain is locked", async () => { + mockAccount({ ...signedOut, keychainUnavailable: true }); + render(); + const entry = await (await sidebar()).findByRole("button", { name: "Account" }); + expect(entry).toHaveTextContent("Account"); + expect(entry).not.toHaveTextContent("Sign in"); + }); + + it("leaves the Account page from the sidebar like any other page", async () => { + mockAccount(signedIn); + render(); + const entry = await (await sidebar()).findByRole("button", { name: /^Account, signed in/ }); + fireEvent.click(entry); + await screen.findByRole("heading", { name: "Account", level: 1 }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(await screen.findByRole("heading", { name: "Settings", level: 1 })).toBeInTheDocument(); + expect(entry).not.toHaveAttribute("aria-current"); + }); + + it("keeps an unsaved profile edit when navigation is cancelled", async () => { + mockAccount(signedIn); + vi.spyOn(window, "confirm").mockReturnValue(false); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); + fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); + fireEvent.click(await screen.findByRole("button", { name: "New profile" })); + fireEvent.change(screen.getByLabelText("Profile name"), { target: { value: "Unsaved" } }); + fireEvent.click(await (await sidebar()).findByRole("button", { name: /^Account, signed in/ })); + expect(window.confirm).toHaveBeenCalledWith("Discard unsaved profile changes?"); + expect(screen.getByLabelText("Profile name")).toHaveValue("Unsaved"); + expect(screen.queryByRole("heading", { name: "Account", level: 1 })).not.toBeInTheDocument(); + }); + + it("keeps the version in builds without accounts", async () => { + mockAccount({ ...signedOut, available: false }); + render(); + expect(await (await sidebar()).findByText(`v${browserState.version}`)).toBeInTheDocument(); + expect((await sidebar()).queryByRole("button", { name: /account/i })).not.toBeInTheDocument(); + }); + }); + it("refreshes Switch Forwarding profiles when settings sync changes them", async () => { let changed: () => void = () => undefined; vi.spyOn(api, "onProfilesChanged").mockImplementation(async (handler) => { changed = handler; return () => undefined; }); diff --git a/src/App.tsx b/src/App.tsx index df242f55..f2856859 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,12 +2,13 @@ import { Button, Input, Select } from "./ui/controls"; import { Demonstration, DemonstrationProvider } from "./help/Demonstration"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { - Accessibility, Bluetooth, ChevronRight, CircleHelp, Download, + Accessibility, Bluetooth, ChevronRight, CircleHelp, CircleUserRound, Download, Copy, Home, Keyboard, MousePointer2, Plus, Power, RefreshCw, Save, Settings, SlidersHorizontal, Smartphone, Trash2, Wrench, X, } from "lucide-react"; import { api, type ProfileExitAction } from "./api"; -import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; +import { AccountSection } from "./settings/AccountSection"; +import type { AccountView, AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { MouseSettingsView } from "./settings/MouseSettingsView"; @@ -22,7 +23,7 @@ import { interfaceModeAdvice, reservedKeyAdvice } from "./help/keyAvailability"; import { SwitchesSection } from "./settings/SwitchesSection"; import { ScanningSection } from "./settings/ScanningSection"; -type View = "switches" | "scanning" | "mouse" | "mobile" | "home" | "devices" | "profiles" | "settings" | "support"; +type View = "switches" | "scanning" | "mouse" | "mobile" | "home" | "devices" | "profiles" | "settings" | "support" | "account"; const brandIconUrl = new URL("../src-tauri/icons/icon.png", import.meta.url).href; const mobileQrUrl = new URL("./assets/mobile-download-qr.png", import.meta.url).href; @@ -45,6 +46,23 @@ const bluetoothDescriptions: Record = { error: "Bluetooth could not start. Try restarting Switchify PC.", }; +// Replaces the version label: who is signed in, and the way to the Account +// page. Builds without accounts keep the version, which Settings → Updates +// also shows. +function SidebarAccount({ account, version, active, onOpen }: { account: AccountView | null; version: string; active: boolean; onOpen: () => void }) { + if (!account?.available) return v{version}; + // A locked keychain may hide a saved sign-in, so stay neutral then. + const signedOut = !account.signedIn && !account.keychainUnavailable; + const label = account.signedIn && account.email ? `Account, signed in as ${account.email}` : signedOut ? "Sign in to your account" : "Account"; + return ; +} + function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) { return ; } @@ -508,6 +526,7 @@ export function App() { const [settings, setSettings] = useState(null); const [busy, setBusy] = useState(false); const [focusUpdates, setFocusUpdates] = useState(false); + const [account, setAccount] = useState(null); // --- Update failures away from the Updates tab ------------------------------ // Failed and cancelled are the updater states with something to act on that // the global banner deliberately leaves out: the scheduled check fails for @@ -695,6 +714,14 @@ export function App() { return () => unlisten(); }, []); + useEffect(() => { + let active = true; + // Read-only: loading the sidebar must never prompt for the keychain. + void api.accountStatus().then((view) => { if (active) setAccount(view); }).catch(() => undefined); + const stop = api.onAccount((view) => setAccount(view)); + return () => { active = false; void stop.then((unlisten) => unlisten()); }; + }, []); + useEffect(() => { let unlisten: () => void = () => {}; let disposed = false; @@ -792,7 +819,7 @@ export function App() { if (!state || !settings) return
Starting Switchify PC...
; return 0 || switches.capturing || !!switches.state?.capture.active}>
- + 0 || switches.capturing || !!switches.state?.capture.active}>
0}> {error &&
{error}
} @@ -807,6 +834,7 @@ export function App() { {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />}
} {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} + {view === "account" &&

Account

Bring your settings to your other computers.

} {view === "support" && void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />}
{setupOpen && 0} busy={busy} error={error} skip={skipSetup} finish={finishSetup} accessibility={() => perform(() => api.checkAccessibility(true))} />} diff --git a/src/api.ts b/src/api.ts index f2614c29..9cb927a9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -115,6 +115,8 @@ export const api = { installUpdate: () => call("install_update"), exportDiagnostics: () => call("export_diagnostics"), account: () => accountCall("get_account"), + /** The account without re-reading the keychain, for background views. */ + accountStatus: () => accountCall("get_account_status"), requestSignInCode: (email: string) => accountCall("request_sign_in_code", { email }), verifySignInCode: (code: string) => accountCall("verify_sign_in_code", { code }), cancelSignIn: () => accountCall("cancel_sign_in"), diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index 689fb958..3326417a 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -229,7 +229,7 @@ describe("AccountSection", () => { expect(elsewhere).toHaveFocus(); }); - it("does not move focus when the tab opens with a pending code", async () => { + it("does not move focus when the page opens with a pending code", async () => { start(pending); const field = await screen.findByLabelText("Code from the email"); expect(field).not.toHaveFocus(); @@ -253,11 +253,10 @@ describe("AccountSection", () => { expect(await screen.findByRole("button", { name: "Sign out" })).toBeInTheDocument(); }); - it("is reachable from the Settings tabs", async () => { + it("is not a Settings tab", async () => { const { App } = await import("../App"); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - fireEvent.click(screen.getByRole("tab", { name: "Account" })); - expect(await screen.findByRole("heading", { name: "Account" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Account" })).not.toBeInTheDocument(); }); }); diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index a34a1a0c..648e058b 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -4,7 +4,7 @@ import { Button, Input } from "../ui/controls"; import type { AccountView, SyncView } from "../types"; import { SettingGroup } from "./controls"; -const description = "Sign in to bring your settings to your other computers. It is the same account as Switchify on Android. We email you a code; there is no password."; +const description = "The same account as Switchify on Android. We email you a code to sign in; there is no password."; type FocusTarget = "email" | "code" | "signOut" | "deleteAccount" | "keepAccount"; type Screen = "loading" | "unavailable" | "keychain" | "signedIn" | "pending" | "signedOut"; @@ -24,7 +24,7 @@ export function AccountSection() { const [error, setError] = useState<{ text: string; id: number } | null>(null); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); - // Set only by the user's own actions, so opening the tab never moves focus. + // Set only by the user's own actions, so opening the page never moves focus. const [focusNext, setFocusNext] = useState(null); const emailRef = useRef(null); const codeRef = useRef(null); @@ -50,7 +50,7 @@ export function AccountSection() { // middle of an action. Never carry a delete confirmation or a code into // another screen, and if the focused control vanished, focus the new // screen instead of leaving focus on the page. Focus that is still on a - // control (such as the tabs) is left alone. + // control (such as the sidebar) is left alone. const keychainRef = useRef(null); const screen = screenOf(account); const previousScreen = useRef(screen); @@ -108,22 +108,22 @@ export function AccountSection() { void run(() => api.verifySignInCode(code), () => "signOut").then((ok) => { if (ok) setCode(""); }); }; - if (!account) return

Loading…

; + if (!account) return

Loading…

; // Keyed by occurrence so the same message is announced again. const errorText = error && {error.text}; const errorProps = { "aria-invalid": Boolean(error), "aria-describedby": error ? "account-error" : undefined }; if (!account.available) { - return

Accounts are unavailable in this build.

; + return

Accounts are unavailable in this build.

; } if (account.keychainUnavailable) { - return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; + return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then open this page again.

; } if (account.signedIn) { - return + return

Signed in as {account.email}.

{!confirmDelete && } {confirmDelete @@ -143,7 +143,7 @@ export function AccountSection() { } if (account.pendingEmail) { - return + return

We sent a code to {account.pendingEmail}. It can take a minute to arrive.