From 8a418107e49c9ab5743294538407df5924bb0e9e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 12:25:56 -0700 Subject: [PATCH] Add "Play test sound" and "Send test push" to the alarm settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An alarm you cannot observe until it fires unattended is one you cannot trust, and that first firing is exactly when being wrong costs the most. Give each alarm sink a way to try it now. 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 outcome inline, because for both sinks a working path and a broken one produce the same observation — silence. Play test sound deliberately does not route through `speak()`: that publishes the transient per-Session speaking/spoken state that Panes and Doors render, and no Session rang. A test that lit up a pane would be claiming some terminal wants attention. 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. That needed the delivery path to become observable: `sendPush` now returns a summary instead of `void`, and a new `pushTest` service command surfaces failures rather than warning to the console. The ring path's rule that a failed push must never break the alert path is right for an alarm and exactly wrong for a test — it would report success over a fan-out that reached nobody. Four outcomes are distinguished: nothing targeted (the ordinary answer on a freshly enrolled machine, not a failure), nothing delivered, a partial fan-out, and success. Note the signature change: `sendPush` returns `Promise` rather than `Promise`. The ring path ignores the value, so behavior is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi --- docs/specs/alert.md | 4 + lib/src/components/AlarmTestButtons.test.tsx | 144 +++++++++++++++++++ lib/src/components/AlarmTestButtons.tsx | 130 +++++++++++++++++ lib/src/components/SettingsDialog.tsx | 34 +++-- lib/src/host/remote/service.test.ts | 52 +++++++ lib/src/host/remote/service.ts | 30 +++- lib/src/lib/alert-speech.ts | 38 +++++ lib/src/remote/host/host-status-store.ts | 33 +++++ lib/src/remote/host/push-delivery.ts | 30 +++- 9 files changed, 482 insertions(+), 13 deletions(-) create mode 100644 lib/src/components/AlarmTestButtons.test.tsx create mode 100644 lib/src/components/AlarmTestButtons.tsx 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 }; }