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
51 changes: 50 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 { AppSettings, SwitchProfile } from "./types";
import type { AppSettings, BluetoothState, SwitchProfile } from "./types";

const defaultBrowserSettings = structuredClone(browserState.settings);

Expand All @@ -13,9 +13,11 @@ function stateWithSettings(settings: AppSettings) {
describe("Switchify PC shell", () => {
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 };
Expand Down Expand Up @@ -58,6 +60,53 @@ describe("Switchify PC shell", () => {
checkForUpdates.mockRestore();
});

it("keeps internal activity out of Home and Support while retaining troubleshooting history", async () => {
browserState.bluetooth = "advertising";
browserState.lastActivity = { kind: "error", message: "Internal runtime activity" };
browserState.diagnostics = {
recentBluetooth: [{ sequence: 1, timestamp: 1, category: "bluetooth", status: "advertising" }],
lastDisconnect: null,
recentErrors: [{ sequence: 2, timestamp: 2, category: "runtime", status: "failed", detail: "Bluetooth adapter failed" }],
};

render(<App />);

await screen.findByRole("heading", { name: "Switchify PC" });
expect(screen.getByText("Waiting for a nearby Android device.")).toBeInTheDocument();
expect(screen.queryByText("Recent activity")).not.toBeInTheDocument();
expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Support" }));
fireEvent.click(await screen.findByRole("tab", { name: "Troubleshooting" }));
expect(screen.queryByText("Recent activity")).not.toBeInTheDocument();
expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Recent errors" })).toBeInTheDocument();
expect(screen.getByText("Bluetooth adapter failed")).toBeInTheDocument();
});

it.each([
["initializing", "Stale tablet", "Preparing this computer for nearby devices."],
["advertising", "Stale tablet", "Waiting for a nearby Android device."],
["connected", null, "Android device connected."],
["connected", "Pixel Tablet", "Pixel Tablet"],
["poweredOff", "Stale tablet", "Turn on Bluetooth to connect an Android device."],
["unauthorized", "Stale tablet", "Allow Bluetooth access in System Settings to connect."],
["conflict", "Stale tablet", "Quit the other Switchify PC instance, then reopen this app."],
["unsupported", "Stale tablet", "This computer does not support the required Bluetooth features."],
["error", "Stale tablet", "Bluetooth could not start. Try restarting Switchify PC."],
] satisfies Array<[BluetoothState, string | null, string]>)('uses recovery-appropriate Home copy for Bluetooth state "%s"', async (bluetooth, deviceName, description) => {
browserState.bluetooth = bluetooth;
browserState.connectedDeviceName = deviceName;
browserState.lastActivity = { kind: "error", message: "Internal runtime activity" };

render(<App />);

await screen.findByRole("heading", { name: "Switchify PC" });
expect(screen.getByText(description)).toBeInTheDocument();
if (bluetooth !== "connected") expect(screen.queryByText("Stale tablet")).not.toBeInTheDocument();
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));
Expand Down
15 changes: 12 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ const bluetoothLabels: Record<AppState["bluetooth"], string> = {
conflict: "Current Switchify PC is running", unsupported: "Bluetooth unavailable", error: "Bluetooth unavailable",
};

const bluetoothDescriptions: Record<AppState["bluetooth"], string> = {
initializing: "Preparing this computer for nearby devices.",
advertising: "Waiting for a nearby Android device.",
connected: "Android device connected.",
poweredOff: "Turn on Bluetooth to connect an Android device.",
unauthorized: "Allow Bluetooth access in System Settings to connect.",
conflict: "Quit the other Switchify PC instance, then reopen this app.",
unsupported: "This computer does not support the required Bluetooth features.",
error: "Bluetooth could not start. Try restarting Switchify PC.",
};

function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) {
return <button className="nav-button" data-active={active} onClick={onClick}>{icon}<span>{children}</span></button>;
}
Expand Down Expand Up @@ -47,15 +58,14 @@ function HomeView({ state, onDisconnect, onAccessibility, onSetup }: { state: Ap
<header className="page-header"><div><h1>Switchify PC</h1><p>Android control for this computer</p></div></header>
<section className="connection-band" data-connected={state.bluetooth === "connected"}>
<StatusIcon ok={bluetoothOk}>{bluetoothOk ? <Radio size={20} /> : <WifiOff size={20} />}</StatusIcon>
<div><h2>{bluetoothLabels[state.bluetooth]}</h2><p>{state.connectedDeviceName ?? state.lastActivity?.message ?? "Waiting for a nearby Android device."}</p></div>
<div><h2>{bluetoothLabels[state.bluetooth]}</h2><p>{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}</p></div>
{state.bluetooth === "connected" ? <button className="secondary" onClick={onDisconnect}><Power size={16} />Disconnect</button> : <button className="secondary" onClick={onSetup}><Wrench size={16} />Set up</button>}
</section>
<section className="status-list" aria-label="System status">
<article><StatusIcon ok={bluetoothOk}><Bluetooth size={19} /></StatusIcon><div><h3>Bluetooth</h3><p>{bluetoothLabels[state.bluetooth]}</p></div></article>
<article><StatusIcon ok={state.accessibility === "granted"}><Accessibility size={19} /></StatusIcon><div><h3>Input access</h3><AccessibilityCopy state={state} /></div>{state.accessibility === "required" && <button className="text-button" onClick={onAccessibility}>Open Accessibility Settings</button>}</article>
<article><StatusIcon ok><ShieldCheck size={19} /></StatusIcon><div><h3>Secure pairing</h3><p>{state.pairedDevices.length === 0 ? "No saved devices" : `${state.pairedDevices.length} saved device${state.pairedDevices.length === 1 ? "" : "s"}`}</p></div></article>
</section>
<section className="activity-panel" aria-live="polite"><span>Recent activity</span><p data-kind={state.lastActivity?.kind}>{state.lastActivity?.message ?? "No recent activity."}</p></section>
</div>;
}

Expand Down Expand Up @@ -406,7 +416,6 @@ function SupportView({ state, busy, perform, openSetup }: { state: AppState; bus
<article className="diagnostic-detail"><Power size={20} /><div><h2>Last disconnect</h2><p>{state.diagnostics.lastDisconnect ? `${state.diagnostics.lastDisconnect.detail ?? state.diagnostics.lastDisconnect.status}` : "No disconnect recorded yet"}</p></div></article>
<article className="diagnostic-detail"><CircleHelp size={20} /><div><h2>Recent errors</h2><p>{state.diagnostics.recentErrors.length > 0 ? state.diagnostics.recentErrors.map((event) => event.detail ?? event.status).join(" · ") : "No recent errors"}</p></div></article>
</section>}
{state.lastActivity && <section className="activity-panel" aria-live="polite"><span>Recent activity</span><p data-kind={state.lastActivity.kind}>{state.lastActivity.message}</p></section>}
</div>;
}

Expand Down
5 changes: 0 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,6 @@ main { min-width: 0; }
.status-list h3 { font-size: 14px; }
.status-list p { margin-top: 3px; color: var(--muted); font-size: 13px; }
.status-list .status-icon { width: 34px; height: 34px; }
.activity-panel { margin-top: 24px; border-left: 3px solid var(--inactive); padding: 12px 15px; background: var(--surface-muted); }
.activity-panel span { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }
.activity-panel p { margin-top: 5px; font-size: 13px; }
.activity-panel p[data-kind="success"] { color: var(--status-ok); }
.activity-panel p[data-kind="error"] { color: var(--status-error); }

.primary, .secondary, .text-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; border-radius: 6px; padding: 0 14px; cursor: pointer; }
.primary { border: 1px solid var(--brand); color: var(--on-brand); background: var(--brand); font-weight: 650; }
Expand Down