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
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/permissions/autogenerated/get_account_status.toml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion src-tauri/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 13 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,9 @@ async fn get_account(
account: State<'_, account::Account>,
) -> Result<account::AccountView, String> {
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 {
Expand All @@ -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<account::AccountView, String> {
Ok(account.current_view().await)
}

#[tauri::command]
async fn request_sign_in_code(
app: AppHandle,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 97 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof vi.spyOn> | 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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; });
Expand Down
36 changes: 32 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -45,6 +46,23 @@ const bluetoothDescriptions: Record<AppState["bluetooth"], string> = {
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 <span>v{version}</span>;
// 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 <Button className="nav-button sidebar-account" data-active={active} aria-current={active ? "page" : undefined} aria-label={label} onClick={onOpen}>
<CircleUserRound size={19} aria-hidden="true" />
<span className="sidebar-account-text">
<span>{signedOut ? "Sign in" : "Account"}</span>
{account.signedIn && account.email && <small title={account.email}>{account.email}</small>}
</span>
</Button>;
}

function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) {
return <Button className="nav-button" data-active={active} aria-current={active ? "page" : undefined} onClick={onClick}>{icon}<span>{children}</span></Button>;
}
Expand Down Expand Up @@ -508,6 +526,7 @@ export function App() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [busy, setBusy] = useState(false);
const [focusUpdates, setFocusUpdates] = useState(false);
const [account, setAccount] = useState<AccountView | null>(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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -792,7 +819,7 @@ export function App() {

if (!state || !settings) return <div className="loading"><RefreshCw className="spin" size={24} /><span>Starting Switchify PC...</span></div>;
return <DemonstrationProvider platform={state.capabilities.platform} suspended={state.pendingPairings.length > 0 || switches.capturing || !!switches.state?.capture.active}><div className="app-shell">
<aside inert={setupOpen || state.pendingPairings.length > 0}><div className="brand"><img className="brand-mark" src={brandIconUrl} alt="" aria-hidden="true" /><div><strong>Switchify</strong><small>PC</small></div></div><nav>{nav.map(([id, label, icon]) => <NavButton key={id} active={view === id || (id === "mobile" && (view === "devices" || view === "profiles"))} icon={icon} onClick={() => selectView(id)}>{label}</NavButton>)}</nav><div className="sidebar-footer"><span>v{state.version}</span></div></aside>
<aside inert={setupOpen || state.pendingPairings.length > 0}><div className="brand"><img className="brand-mark" src={brandIconUrl} alt="" aria-hidden="true" /><div><strong>Switchify</strong><small>PC</small></div></div><nav>{nav.map(([id, label, icon]) => <NavButton key={id} active={view === id || (id === "mobile" && (view === "devices" || view === "profiles"))} icon={icon} onClick={() => selectView(id)}>{label}</NavButton>)}</nav><div className="sidebar-footer"><SidebarAccount account={account} version={state.version} active={view === "account"} onOpen={() => selectView("account")} /></div></aside>
<DemonstrationProvider platform={state.capabilities.platform} suspended={setupOpen || state.pendingPairings.length > 0 || switches.capturing || !!switches.state?.capture.active}><main inert={setupOpen || state.pendingPairings.length > 0}>
{error && <div className="error-banner" role="alert">{error}<Button onClick={() => setError(null)}>Dismiss</Button></div>}
<UpdateBanner update={state.updater} openUpdates={openUpdates} />
Expand All @@ -807,6 +834,7 @@ export function App() {
{view === "profiles" && <ProfilesView profiles={profiles} platform={state.capabilities.platform} busy={busy} saveProfile={saveProfile} deleteProfile={deleteProfile} onDirtyChange={(dirty) => { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />}
</TabPanel></div>}
{view === "settings" && <SettingsView state={state} settings={settings} onChange={changeSettings} chooseTelemetry={(enabled) => 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" && <div className="view"><header className="page-header"><div><h1>Account</h1><p>Bring your settings to your other computers.</p></div></header><AccountSection /></div>}
{view === "support" && <SupportView state={state} switches={switches} busy={busy} perform={(operation) => void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />}
</main></DemonstrationProvider>
{setupOpen && <SetupGuide state={state} switches={switches} suspended={state.pendingPairings.length > 0} busy={busy} error={error} skip={skipSetup} finish={finishSetup} accessibility={() => perform(() => api.checkAccessibility(true))} />}
Expand Down
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export const api = {
installUpdate: () => call<AppState>("install_update"),
exportDiagnostics: () => call<AppState>("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"),
Expand Down
7 changes: 3 additions & 4 deletions src/settings/AccountSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(<App />);
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();
});
});
Loading
Loading