Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b7009fb
Pocket: installable web app with a service worker
nedtwigg Jul 29, 2026
4507b21
Server: Web Push subscriptions and host-triggered delivery
nedtwigg Jul 29, 2026
0587ef5
Alerts: push an unattended alarm to paired phones
nedtwigg Jul 29, 2026
eeee5fe
Pocket: document the installed app's separate storage partition
nedtwigg Jul 29, 2026
4df7dab
Pocket: detect install state and name every reason push is unavailable
nedtwigg Jul 29, 2026
a2c56c2
Push: cleanup pass — dedupe, drop a round trip, fix a binary test file
nedtwigg Jul 29, 2026
ad3c199
fix(pocket): track push registration per host
nedtwigg Jul 29, 2026
542d185
fix(pocket): reuse matching push subscription
nedtwigg Jul 29, 2026
469e436
fix(pocket): sanitize push text in service worker
nedtwigg Jul 29, 2026
7f7e633
fix(server): reject mismatched VAPID keypairs
nedtwigg Jul 29, 2026
1cb9a2c
Push: enforce the sw.js/boundedPushText mirror, and fix the HostsView…
nedtwigg Jul 29, 2026
8c7c9fb
fix(pocket): report needs-install before probing push capabilities
nedtwigg Jul 29, 2026
892a5eb
fix(pocket): treat a failed push-config fetch as unknown, not disabled
nedtwigg Jul 29, 2026
d942356
fix(pocket): hide the install notice when the server cannot push
nedtwigg Jul 29, 2026
e4ef8e3
fix(push): strip U+061C and cap text on code points
nedtwigg Jul 29, 2026
71bdcec
fix(pocket): never let a push payload skip the notification
nedtwigg Jul 29, 2026
1461b67
fix(host): discard push-device refreshes that outlive their Host
nedtwigg Jul 29, 2026
c419967
fix(server): surface failed push deliveries
nedtwigg Jul 29, 2026
39f8c9e
docs(specs): sync push specs with the branch
nedtwigg Jul 29, 2026
cbed6e1
fix(pocket): preserve push permission gesture
nedtwigg Jul 29, 2026
b1b09e7
fix(host): order push-device refreshes
nedtwigg Jul 29, 2026
dd722b6
fix(server): validate VAPID subject at startup
nedtwigg Jul 29, 2026
2aa09a8
feat(push): read back which Hosts this device already registered
nedtwigg Jul 29, 2026
72e0197
feat(remote): account-centric UX over the device-centric security model
nedtwigg Jul 29, 2026
ba76887
fix(pocket): repair stale push registrations
nedtwigg Jul 29, 2026
c9e9c6c
fix(pocket): merge concurrent push registrations
nedtwigg Jul 29, 2026
ac85390
fix(pocket): clear stale push readback
nedtwigg Jul 29, 2026
ba577c2
fix(pocket): correct passkey recovery guidance
nedtwigg Jul 29, 2026
59ea72c
fix(server): restrict push endpoint egress
nedtwigg Jul 29, 2026
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
44 changes: 27 additions & 17 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Internally these are three independent tracks — `watchingRingingCommand` + the
## Non-goals

- No process heuristics. Dormouse never decides on its own that `vim`, `npm dev`, agents, or test runners deserve alerts. WATCHING applies only to command names the user explicitly asked for.
- No native OS notifications, browser notifications, or separate progress-bar widget. The one audible channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects.
- No native OS notifications on the machine Dormouse runs on, and no separate progress-bar widget. The one audible local channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. Push notifications are the deliberate exception and go only to a *remote* paired phone, never to this machine — the point is reaching a user who walked away.
- No process-tree introspection for command-exit alerts; normalized terminal semantic events are the reliable input.
- No HTML, Markdown, ANSI styling, clickable actions, custom icons, or remote-controlled buttons in notification previews.
- No Door-specific alert menu that changes the Door actions defined in `docs/specs/layout.md`.
Expand Down Expand Up @@ -139,7 +139,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi
|---|---|
| `inactivityTimeoutMs` | `T_USER_ATTENTION` — the walk-away window defined under Attention above. |
| `speakEnabled` / `speakDelayMs` | Spoken alarms, below. |
| `pushEnabled` / `pushDelayMs` | Reserved for the push notifications in `## Future`. Persisted and relayed so the field shape does not change when push lands, but the UI renders the whole group disabled and nothing reads them. |
| `pushEnabled` / `pushDelayMs` | Push notifications, below. |

Rules:

Expand All @@ -160,13 +160,30 @@ When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelay
- Renderer-side, via `window.speechSynthesis`. Where that is absent — Tauri on Linux (WebKitGTK ships no speech backend), or a test environment — speaking is a silent no-op rather than an error. `speak()` is the single seam a native host path would replace.
- Desktop shell only: `MobileWall` / Pocket does not arm it and has no settings UI.

### Push notifications

When a Session transitions into `ALERT_RINGING` and is still ringing `pushDelayMs` later, Dormouse sends that Pane's name to every paired phone that has enabled alerts. Source of truth: `lib/src/remote/host/alert-push.ts`, armed by `activateRemoteHost` alongside the remote Host. Desktop shell only, and only where a Host runs — a build with no enrollment has nowhere to push. Living under `remote/host/` keeps the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere.

Push and speech are independent: both fire when both are on, each on its own delay.

- **The trigger is shared with spoken alarms**, not reimplemented: `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts` owns fresh-ring detection, the delay, the fire-time re-check, and every cancellation rule, with speech and push as two sinks over it. A Session observed for the first time *already* ringing never pushes, which is what keeps a restored session blob from buzzing the phone at every app launch.
- **The derived Pane label is the payload**, on the same rule as speech: the ringing `ActivityNotification`'s title/body is not selected as the payload, but terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can appear when it is the winning Pane label. The body is a fixed string; the Pane name carries the information.
- **The label is sanitized by `toPushText` — the sink's cap and fallback over the shared `boundedPushText` — which is deliberately not `toSpokenText`.** The rule keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `boundedPushText` lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing.
- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host deliberately does **not** ask which devices are subscribed first: the Server applies that filter anyway, so the target set is identical and the alarm costs one round trip instead of two.
- **One notification per Session at a time.** Each push carries the Session id as a collapse tag, so a Pane that rings, is cleared, and rings again replaces its own notification rather than stacking copies on the lock screen.
- **Attending before `pushDelayMs` cancels**, matching speech. A push already delivered is *not* recalled: reaching the phone again means sending a second push, and `userVisibleOnly` guarantees that would itself be visible — so recall would trade one stale notification for one confusing one.
- Delivery is an HTTP POST to the Server, not a relay frame ([server.md](./server.md) -> Web Push). The relay routes between two live sockets; a push exists to reach a phone whose app is closed.
- A failed send warns and is dropped. That covers both failure classes: a non-2xx response is checked rather than ignored so a revoked host token cannot leave push permanently broken and silent, and a 2xx whose counts report `failed > 0` or `delivered: 0` warns too — the Server answers 200 even when a push service refused every delivery, folding the outcome into the `PushSendResponse` counts (and logging the refusal server-side). There is nothing useful to retry against: by the next ring the alarm is already stale.
- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`). A phone can enable alerts long after this machine booted, so a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. Refresh writes are both Host-generation-fenced and latest-request-wins: a request still in flight when the Host stops (or is replaced by re-enrollment) cannot overwrite `no-host`, and a slow startup request cannot overwrite a newer dialog refresh.


### Settings dialog

Reached from a control at the far right of the baseboard; placement and the baseboard's right cluster belong to `docs/specs/layout.md`. Source of truth: `lib/src/components/AlertSettingsDialog.tsx`.

- 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 renders inside a disabled `<fieldset>` with no device name.
- 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.

## Workspace union

Expand Down Expand Up @@ -222,7 +239,7 @@ Notification text is untrusted terminal output.
- Strip C0/C1 controls after protocol parsing, collapse whitespace controls to spaces, and trim.
- Store at most the `TITLE_LIMIT` / `BODY_LIMIT` code points defined in `lib/src/lib/terminal-protocol.ts`, only the latest `ActivityNotification` rather than unbounded history, and cap/expire incomplete OSC 99 parser state.
- Never execute commands, open URLs, copy to clipboard, read files, focus outside Dormouse, or render protocol-supplied icons/buttons/actions.
- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above — after a second, speech-specific pass, because a label that is safe to *render* is not automatically safe to hand a speech engine. See `toSpokenText` under Spoken alarms.
- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above — after a second, speech-specific pass, because a label that is safe to *render* is not automatically safe to hand a speech engine. See `toSpokenText` under Spoken alarms and `toPushText` under Push notifications — two passes with deliberately different rules, because a speech engine and an OS notification fail in different ways.

Alert-specific robustness requirements: multiple Sessions ring independently; minimize, reattach, rerender, resize, and theme changes preserve existing alert state without creating new rings; an exited Session may keep ringing until attended, dismissed, or destroyed; ringing must not rely on color alone and must respect `prefers-reduced-motion`.

Expand All @@ -236,7 +253,11 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi
| `lib/src/lib/watched-command-host.ts` | First-seed + mutation/broadcast coordinator for a host shared by multiple renderers |
| `lib/src/lib/alert-settings.ts` | Persisted alarm settings, their validation/clamping, and their push to the host |
| `lib/src/lib/alert-settings-host.ts` | First-seed + replace/broadcast coordinator for the settings blob |
| `lib/src/lib/alert-speech.ts` | Fresh-ring detection, the delay, and the spoken Pane label |
| `lib/src/lib/alert-ring-watch.ts` | The shared unattended-ring machine: fresh-ring detection, the delay, the re-check, cancellation |
| `lib/src/lib/alert-speech.ts` | The speech sink and `toSpokenText` |
| `lib/src/remote/host/alert-push.ts` | The push sink, `toPushText`, and the ACL-intersected target list |
| `lib/src/remote/host/activation.ts` | Arms the push sink for the lifetime of the remote Host (start, stop, re-enroll) |
| `lib/src/lib/push-devices.ts` | Renderer-only store of the devices a push would reach, read by the settings dialog |
| `lib/src/lib/session-label.ts` | `deriveSessionLabel`: the id-keyed Surface label over the live stores |
| `lib/src/components/wall/use-alert-speech.ts` | Arms spoken alarms for the lifetime of the desktop shell |
| `lib/src/lib/terminal-protocol.ts` | Notification/progress OSC parsing (`OSC 9` / `9;4` / `99` / `777`, BEL), sanitization limits, OSC 99 chunk state |
Expand All @@ -245,17 +266,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi
| `lib/src/components/bell-icon-class.ts` | Bell tilt/animation mapping from public status |
| `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview |
| `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list |
| `lib/src/components/AlertSettingsDialog.tsx` | App-global Alarm settings: rule list, inactivity timeout, spoken alarms, disabled push group |
| `lib/src/components/AlertSettingsDialog.tsx` | App-global Alarm settings: rule list, inactivity timeout, spoken alarms, push notifications |
| `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 |

## Future

**Scope: alarm-push** — deliver an unattended alarm to a phone, so the user learns about it away from the machine. `pushEnabled` / `pushDelayMs` already persist and relay (Alarm settings above) and the dialog already renders the group, disabled, so landing this changes no stored shape.

Staged in order:

1. **Device registry.** Reuse the paired-Client identities the Host already holds — `docs/specs/remote-security-model.md` defines the ACL, and a registered Pocket Client is exactly the device a push should reach. The dialog's "Push will be sent to —" line names the chosen device once there is one to name.
2. **Delivery.** A push travels Host -> Server -> Client. The Server relay is the only component that can reach a backgrounded phone, so this needs a wire method beyond the terminal-only protocol-v1 in `docs/specs/remote-api.md`.
3. **Payload rule.** Same rule as spoken alarms: send the derived Pane label rather than the ringing notification's title/body as such. Terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can therefore appear when it is the winning Pane label.
4. **Cancellation.** Attending on the laptop before `pushDelayMs` cancels, matching the speech path. Whether a delivered push can be recalled once the user attends is undecided.
Loading
Loading