diff --git a/docs/specs/alert.md b/docs/specs/alert.md index a7a5b3b3..e75161c6 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -192,6 +192,9 @@ Reached from any of the controls at the far right of the baseboard; placement an - Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation. - Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to. - The push group's device line names every device a push would reach, and otherwise states why there is none — no Host enrolled, nothing subscribed yet, or the server could not be asked. A push that silently goes nowhere is indistinguishable from a broken one. +- Each alarm sink carries a **try it now** control — **Play test sound** and **Send test push** — because an alarm is otherwise unobservable until it fires unattended, which is the moment its being wrong costs the most. Source of truth: `lib/src/components/AlarmTestButtons.tsx`. Both sit outside the switch's dimming and stay enabled while the sink is off: checking that the speakers work, or that the phone buzzes, is most useful *before* committing to the alarm. Each reports its own outcome inline and clears it after a few seconds, because for both sinks a working path and a broken one produce the same observation — silence. + - **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but deliberately not through `speak()`: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does. + - **Send test push** goes through the real Host, ACL and server, so what it proves is what the alarm will do. It is the one caller of the push path that must **not** swallow failures — the ring path's rule that a failed push never breaks the alert path would make a test button report success over a fan-out that reached nobody. It distinguishes four outcomes: no devices targeted (the ordinary answer on a freshly enrolled machine, and not a failure), nothing delivered, a partial fan-out, and success. The button is hidden entirely where no Host service exists, matching the Remote control section ([server.md](./server.md)). ## Workspace union @@ -282,5 +285,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment | | `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | | `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) | +| `lib/src/components/AlarmTestButtons.tsx` | The two alarm sinks' "try it now" controls: Play test sound, Send test push | | `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs | | `lib/src/components/Door.tsx` | Door bell + TODO display | diff --git a/lib/src/components/AlarmTestButtons.test.tsx b/lib/src/components/AlarmTestButtons.test.tsx new file mode 100644 index 00000000..9e6538ad --- /dev/null +++ b/lib/src/components/AlarmTestButtons.test.tsx @@ -0,0 +1,144 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let platform: { remoteHost?: unknown } = {}; + +vi.mock('../lib/platform', () => ({ + IS_MAC: false, + getPlatform: () => platform, +})); + +import { PushTestButton, SpeakTestButton } from './AlarmTestButtons'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +function text(): string { + return container.textContent ?? ''; +} + +function button(): HTMLButtonElement { + const found = container.querySelector('button'); + if (!found) throw new Error('no button rendered'); + return found; +} + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + platform = {}; + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('SpeakTestButton', () => { + it('speaks and says so when the webview has a speech engine', async () => { + const speak = vi.fn(); + vi.stubGlobal('speechSynthesis', { speak, cancel: vi.fn() }); + vi.stubGlobal( + 'SpeechSynthesisUtterance', + class { + constructor(public text: string) {} + }, + ); + + await act(async () => root.render()); + await act(async () => button().click()); + + expect(speak).toHaveBeenCalledTimes(1); + expect(text()).toContain('Speaking now'); + }); + + it('says there is no speech engine rather than looking like it worked', async () => { + // A webview with no backend and one with the volume down produce the same + // observation, so silence has to be reported. + vi.stubGlobal('speechSynthesis', undefined); + + await act(async () => root.render()); + await act(async () => button().click()); + + expect(text()).toContain('no speech engine'); + }); +}); + +describe('PushTestButton', () => { + it('renders nothing where there is no Host service', async () => { + platform = {}; + await act(async () => root.render()); + expect(container.innerHTML).toBe(''); + }); + + it('reports a delivered push', async () => { + platform = { + remoteHost: { + command: vi.fn(async () => ({ targeted: 2, delivered: 2, failed: 0 })), + on: () => () => {}, + respond: () => {}, + notify: () => {}, + }, + }; + await act(async () => root.render()); + await act(async () => button().click()); + + expect(text()).toContain('Sent to 2 devices'); + }); + + it('distinguishes "nowhere to send it" from a failure', async () => { + platform = { + remoteHost: { + command: vi.fn(async () => ({ targeted: 0, delivered: 0, failed: 0 })), + on: () => () => {}, + respond: () => {}, + notify: () => {}, + }, + }; + await act(async () => root.render()); + await act(async () => button().click()); + + expect(text()).toContain('No paired phone has enabled alerts yet'); + }); + + it('reports a fan-out that reached nobody', async () => { + platform = { + remoteHost: { + command: vi.fn(async () => ({ targeted: 2, delivered: 0, failed: 2 })), + on: () => () => {}, + respond: () => {}, + notify: () => {}, + }, + }; + await act(async () => root.render()); + await act(async () => button().click()); + + expect(text()).toContain('No device accepted the push'); + }); + + it('surfaces the service error', async () => { + platform = { + remoteHost: { + command: vi.fn(async () => { + throw new Error('This machine is not connected to a Dormouse server.'); + }), + on: () => () => {}, + respond: () => {}, + notify: () => {}, + }, + }; + await act(async () => root.render()); + await act(async () => button().click()); + + expect(text()).toContain('not connected to a Dormouse server'); + }); +}); diff --git a/lib/src/components/AlarmTestButtons.tsx b/lib/src/components/AlarmTestButtons.tsx new file mode 100644 index 00000000..3ee2732d --- /dev/null +++ b/lib/src/components/AlarmTestButtons.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { modalActionButton } from './design'; +import { speakTestUtterance } from '../lib/alert-speech'; +import { getPlatform } from '../lib/platform'; +import { sendTestPush } from '../remote/host/host-status-store'; + +/** + * "Try it now" controls for the two alarm sinks + * (`docs/specs/alert.md` -> Alarm settings). + * + * Both answer the same question — will this actually reach me? — which is + * otherwise unanswerable until an alarm fires at 3am. Each reports its own + * outcome inline rather than relying on the effect being observable: a silent + * webview and a working one look identical, and a push that reached nobody + * looks exactly like one that did. + * + * The result line clears itself, so the dialog does not accumulate stale + * verdicts from earlier presses. + */ + +/** How long a result line stays before the button returns to its resting state. */ +const RESULT_LINGER_MS = 6000; + +function useTransientResult() { + const [result, setResult] = useState<{ text: string; tone: 'ok' | 'bad' } | null>(null); + const timer = useRef | null>(null); + + const show = useCallback((text: string, tone: 'ok' | 'bad') => { + if (timer.current) clearTimeout(timer.current); + setResult({ text, tone }); + timer.current = setTimeout(() => setResult(null), RESULT_LINGER_MS); + }, []); + + // A dialog closed while a result is showing must not leave a timer holding a + // setState on an unmounted tree. + useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []); + + return [result, show] as const; +} + +function ResultLine({ result }: { result: { text: string; tone: 'ok' | 'bad' } | null }) { + if (!result) return null; + return ( +
+ {result.text} +
+ ); +} + +/** + * Speak a fixed phrase now. Synchronous and local — there is no server in this + * path — so the only failure worth reporting is a webview with no speech + * backend at all, which would otherwise be indistinguishable from a working one + * with the volume down. + */ +export function SpeakTestButton() { + const [result, show] = useTransientResult(); + + return ( +
+ + +
+ ); +} + +/** + * Send a real push through the real path — same Host, same ACL, same server — + * so what it proves is what the alarm will do. + * + * Hidden entirely where no Host service exists, matching the Remote control + * section: there is nothing to test and nothing the user could do about it. + */ +export function PushTestButton() { + const [result, show] = useTransientResult(); + const [busy, setBusy] = useState(false); + + let hasService = false; + try { + hasService = !!getPlatform().remoteHost; + } catch { + hasService = false; + } + if (!hasService) return null; + + return ( +
+ + +
+ ); +} diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index ed7200ae..3885d062 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -13,6 +13,7 @@ import { ThemePicker } from './ThemePicker'; import { ShellPicker } from './ShellPicker'; import { WatchedCommandList } from './WatchedCommandList'; import { RemoteControlSection } from './RemoteControlSection'; +import { PushTestButton, SpeakTestButton } from './AlarmTestButtons'; import { getPlatform } from '../lib/platform'; import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store'; import { @@ -173,6 +174,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { delayMs={settings.speakDelayMs} onToggle={(speakEnabled) => updateAlertSettings({ speakEnabled })} onCommitDelay={(speakDelayMs) => updateAlertSettings({ speakDelayMs })} + action={} /> void }) { delayMs={settings.pushDelayMs} onToggle={(pushEnabled) => updateAlertSettings({ pushEnabled })} onCommitDelay={(pushDelayMs) => updateAlertSettings({ pushDelayMs })} + action={} > {describePushTargets(push)} @@ -207,6 +210,7 @@ function AlarmSinkSection({ onToggle, onCommitDelay, children, + action, }: { switchLabel: string; delayLabel: string; @@ -215,20 +219,30 @@ function AlarmSinkSection({ onToggle: (next: boolean) => void; onCommitDelay: (ms: number) => void; children?: React.ReactNode; + /** + * A "try it now" control. Rendered *outside* the dimming below, and never + * disabled by the switch: checking that the speakers work — or that the phone + * buzzes — is most useful before committing to the alarm, and an alarm you + * cannot observe until 3am is one you cannot trust. + */ + action?: React.ReactNode; }) { return (
-
- - {children ? ( -
{children}
- ) : null} +
+
+ + {children ? ( +
{children}
+ ) : null} +
+ {action ?
{action}
: null}
); diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index b901cdf1..14ca74a9 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -759,3 +759,55 @@ describe('pushDevices', () => { expect((await command('pushDevices')).error).toBeTruthy(); }); }); + +describe('pushTest', () => { + it('refuses when this machine is not connected to a server', async () => { + createService(); + // The inverse of the ring path, which swallows everything: a test button + // that reported success here would be worse than no button. + const { error } = await command('pushTest'); + expect(error).toContain('not connected'); + }); + + it('reports that nothing was targeted when no device is authorized', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [] } }); + await service.start(); + + const { result } = await command('pushTest'); + // Distinct from a refused send: the Host is fine, nothing has opted in. + expect(result).toEqual({ targeted: 0, delivered: 0, failed: 0 }); + expect(requests.some((request) => request.url.endsWith('/api/push/send'))).toBe(false); + }); + + it('sends through the real path and reports what was delivered', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + await service.start(); + + const { result } = await command('pushTest'); + expect(result).toEqual({ targeted: 1, delivered: 1, failed: 0 }); + + const send = requests.find((request) => request.url.endsWith('/api/push/send')); + expect(send).toBeTruthy(); + const body = JSON.parse(String(send!.init?.body)) as Record; + // Recipients come from the ACL, exactly as a real ring does. + expect(body.devicePublicKeys).toEqual(['device-1']); + // A fixed collapse key, so repeated presses replace rather than stack. + expect(body.tag).toBe('dormouse-push-test'); + expect(String(body.title)).toContain('test'); + }); + + it('surfaces a refused send instead of swallowing it', async () => { + store = memoryStore({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + service = new RemoteHostService({ + store, + provider: fakeProvider(), + sendToUi: (event, data) => sent.push({ event, data: data as Record }), + connectSrc: CONNECT_SRC, + createWebSocket: () => new FakeSocket(), + fetch: (async () => ({ ok: false, status: 500 })) as unknown as typeof globalThis.fetch, + }); + await service.start(); + + expect((await command('pushTest')).error).toBeTruthy(); + }); +}); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index c61853d2..08e34a75 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -22,7 +22,14 @@ import { filterAclRecords } from '../../remote/host/acl'; import { isEnrollment, performEnrollment, type HostEnrollment } from '../../remote/host/enrollment'; import type { HostSurfaceProvider } from '../../remote/host/host-surface-provider'; import type { PendingPairing } from '../../remote/host/pairing-approval'; -import { loadPushDevices, sendPush, type AlertPushDeps } from '../../remote/host/push-delivery'; +import { + loadPushDevices, + sendPush, + PUSH_TEST_TAG, + PUSH_TEST_TITLE, + type AlertPushDeps, + type PushSendSummary, +} from '../../remote/host/push-delivery'; import { RemoteApiSession } from '../../remote/host/remote-api'; import { RemoteHost, type WebSocketLike } from '../../remote/host/remote-host'; import { originAllowedByConnectSrc } from './connect-src'; @@ -162,6 +169,8 @@ export class RemoteHostService { return this.#deny(params as DenyParams); case 'push': return this.#push(params as PushParams); + case 'pushTest': + return this.#pushTest(); case 'pushDevices': return this.#pushDevices(); case 'pairingQueue': @@ -278,6 +287,25 @@ export class RemoteHostService { return {}; } + /** + * The Settings dialog's "Send test push". + * + * The inverse of {@link #push} in the one way that matters: nothing is + * swallowed. A test whose whole purpose is to report an outcome must let the + * failure through, so an unenrolled machine, an unreachable server, and a + * fan-out that reached nobody all read differently at the button. + */ + async #pushTest(): Promise { + const deps = this.#pushDeps(); + if (!deps) { + throw new Error('This machine is not connected to a Dormouse server.'); + } + // A fixed tag, so pressing the button repeatedly replaces the notification + // on the phone rather than stacking copies — the same per-Session collapse + // rule the ring path uses, with the test as its own "Session". + return await sendPush(deps, PUSH_TEST_TAG, PUSH_TEST_TITLE); + } + async #pushDevices(): Promise { const deps = this.#pushDeps(); if (!deps) return null; diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index 258277e3..ca67759a 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -123,6 +123,44 @@ function cancelSpeech(): void { globalThis.speechSynthesis?.cancel(); } +/** What the Settings dialog's test button says. Not a pane label — nothing rang. */ +const TEST_UTTERANCE = 'Dormouse alarm test'; + +/** + * Say a fixed phrase so the Settings dialog can prove the alarm is audible now, + * rather than at 3am when a build finally finishes. + * + * Deliberately *not* routed through `speak()`: that publishes the transient + * per-Session `speaking` / `spoken` state that Panes and Doors render, and no + * Session rang here. A test that made a pane light up would be lying about + * which terminal wants attention. + * + * Returns `false` when this webview has no speech backend — the same + * degradation `speak()` makes silently (jsdom, Tauri on WebKitGTK). The button + * needs to tell those apart from a working engine, because "nothing happened" + * is the identical observation for both. + */ +export function speakTestUtterance(): boolean { + const synth = globalThis.speechSynthesis; + if (!synth || typeof globalThis.SpeechSynthesisUtterance !== 'function') return false; + + let utterance: SpeechSynthesisUtterance; + try { + utterance = new globalThis.SpeechSynthesisUtterance(toSpokenText(TEST_UTTERANCE)); + } catch { + return false; + } + try { + // Drop anything queued first: repeated presses should say it once more, not + // stack a backlog behind a slow engine. + synth.cancel(); + synth.speak(utterance); + } catch { + return false; + } + return true; +} + /** * Watch the activity store for fresh rings and speak the unattended ones. * Returns a disposer that cancels pending ring timers, silences the engine, and diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts index 8a5b0f1e..792d1242 100644 --- a/lib/src/remote/host/host-status-store.ts +++ b/lib/src/remote/host/host-status-store.ts @@ -171,6 +171,39 @@ export async function clearRemoteHostEnrollment(): Promise { await refreshRemoteHostStatus(); } +/** + * What "Send test push" reports back. + * + * `targeted: 0` is the ordinary answer on a freshly enrolled machine — the Host + * is fine, no phone has enabled alerts yet — so it is a distinct outcome rather + * than an error. Anything the user could act on differently deserves its own + * answer, and "no devices" and "the server refused" are not the same problem. + */ +export interface PushTestOutcome { + targeted: number; + delivered: number; + failed: number; +} + +/** + * Ask the Host service to send a test push and report what happened. + * + * Rejects when there is no service, no enrollment, or the server refused — + * unlike the ring path, which swallows everything so a failed push can never + * break an alarm (`docs/specs/server.md` -> Web Push). A test button is the one + * caller that needs the failure. + * + * Lives here rather than beside the ring watcher in `alert-push.ts`: that + * module is deliberately inside the lazily-imported `RemotePairingModalHost` + * chunk, and importing it from the Settings dialog would pull the whole + * remote-host stack into the main bundle on every host. + */ +export async function sendTestPush(): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + return (await active.command('pushTest')) as PushTestOutcome; +} + function describeError(error: unknown): string { if (error instanceof Error && error.message) return error.message; if (typeof error === 'string' && error) return error; diff --git a/lib/src/remote/host/push-delivery.ts b/lib/src/remote/host/push-delivery.ts index 7fd29818..3e336fcf 100644 --- a/lib/src/remote/host/push-delivery.ts +++ b/lib/src/remote/host/push-delivery.ts @@ -34,6 +34,16 @@ const PUSH_TITLE_LIMIT = 100; /** Shown as the notification body; the Pane name carries the information. */ const PUSH_BODY = 'Needs attention'; +/** + * The Settings dialog's test push. The title says plainly that nothing is + * actually waiting, so a test that arrives on a phone hours later — or on + * someone else's phone — cannot be mistaken for a real alarm. + */ +export const PUSH_TEST_TITLE = 'Dormouse test — nothing needs attention'; + +/** Collapse key for the test, so repeated presses replace rather than stack. */ +export const PUSH_TEST_TAG = 'dormouse-push-test'; + /** * Apply this sink's bounds to a Pane label. The rule itself is * `boundedPushText` in `server-lib-common`, shared with the Server so the @@ -100,6 +110,21 @@ export async function loadPushDevices(deps: AlertPushDeps): Promise { +): Promise { // Read straight from the ACL, which is local and in-memory, rather than // asking the Server which devices are subscribed: the Server intersects the // names it is given with its own subscriptions anyway, so the target set is @@ -124,7 +149,7 @@ export async function sendPush( // recipients would keep pushing Pane labels to a de-authorized phone. Read at // send time, so a revocation during the delay takes effect. const devicePublicKeys = deps.activeRecords().map((record) => record.devicePublicKey); - if (devicePublicKeys.length === 0) return; + if (devicePublicKeys.length === 0) return { targeted: 0, delivered: 0, failed: 0 }; const response = await hostFetch(deps, API_ROUTES.pushSend, { devicePublicKeys, @@ -143,4 +168,5 @@ export async function sendPush( if (result.failed > 0 || result.delivered === 0) { console.warn('remote-host: push was not delivered to every device', result); } + return { targeted: devicePublicKeys.length, delivered: result.delivered, failed: result.failed }; }