-
- {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 };
}