From 647c640f9b0b5acd05e9546d66d7091eed88ea70 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Sat, 20 Jun 2026 01:12:25 +0700 Subject: [PATCH 1/2] feat(push): harden web push delivery reliability (t095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E1: revocation-proof SW push handler — always show notification - E0: endpoint-reconciled per-device identity — survives storage wipe - E1b: once-per-foreground re-validation gate — iOS PWA recovery - E2: push send options (urgency:high, TTL:1800) for timely delivery - E4: regression guard on Slack health-alert muteKey stamping - Layer 1: pure tests for E1/E0/E1b/E2/E4 components - Layer 2: e2e reconcile keystone test validates endpoint-deduping - All gates green: tests + typecheck + build + web server boot --- CLAUDE.md | 4 + CONTEXT.md | 8 + core/push-send-options.js | 9 ++ core/push-send-options.test.mjs | 21 +++ core/push-subscriptions.js | 18 +++ core/push-subscriptions.test.mjs | 119 +++++++++++++++ ...int-reconciled-per-device-push-identity.md | 57 +++++++ docs/memories/learnings.md | 2 + ...95-harden-web-push-delivery-reliability.md | 144 ++++++++++++++++++ public/sw.js | 62 ++++---- src/app.tsx | 17 +++ src/lib/cdp-web-transport.ts | 33 +++- src/lib/push-notification.test.ts | 109 +++++++++++++ src/lib/push-notification.ts | 32 ++++ src/lib/push-revalidate.test.ts | 52 +++++++ src/lib/push-revalidate.ts | 29 ++++ src/vite-env.d.ts | 5 +- test/e2e/server.e2e.test.ts | 82 ++++++++++ web/server.mjs | 16 +- 19 files changed, 773 insertions(+), 46 deletions(-) create mode 100644 core/push-send-options.js create mode 100644 core/push-send-options.test.mjs create mode 100644 core/push-subscriptions.js create mode 100644 core/push-subscriptions.test.mjs create mode 100644 docs/adr/0014-endpoint-reconciled-per-device-push-identity.md create mode 100644 docs/tasks/done/095-harden-web-push-delivery-reliability.md create mode 100644 src/lib/push-notification.test.ts create mode 100644 src/lib/push-notification.ts create mode 100644 src/lib/push-revalidate.test.ts create mode 100644 src/lib/push-revalidate.ts diff --git a/CLAUDE.md b/CLAUDE.md index b9bb538..08a7b48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,8 @@ cdp-browser/ │ ├── slack-render.js # Pure content renderer: renderBody (mentions/channel-refs/links resolved, mrkdwn stripped, entities decoded) + composeTitle ("{sender} in #{channel}", DM: sender) (t073) + toReaderMessages (reader history shaping, t077). ADR-0011/0012 │ ├── notification-health.js # Pure capture-health aggregator: buildHealth (ONE row per Grid group keyed groupId — merged status/label/teamIds, t092; healthy/degraded/unsupported) + shouldAlert (one-time degrade gate); /api/notifications/health (t074, ADR-0011) │ ├── notif-mutes.js # Pure per-device mute logic: muteKey(entry) (slack→groupKey, else adapter) + isMuted + unreadExcluding(list,mutes,masterOn) (per-device unread, 0 when master off). Mirrored in src/lib/notif-mutes.ts; gates per-device push in web/server.mjs (t093) +│ ├── push-subscriptions.js # Pure E0: endpoint-reconciled deviceId (t095, ADR-0014). reconcileDeviceId(existingSubs, {endpoint}) → {deviceId, isNew} — server-authoritative identity surviving storage wipe +│ ├── push-send-options.js # Pure E2: push notification send options (t095). pushSendOptions() → {urgency:"high", TTL:1800} for timely, non-stale delivery │ (Channel Exclude logic lives in src/lib/slack-excludes.ts — renderer edits ui-state, server sweep reads it, t072) │ └── crypto-envelope.js # Server-side AES-256-GCM seal/open (E2E mode); mirrors src/lib/crypto-envelope.ts ├── web/ @@ -139,6 +141,8 @@ cdp-browser/ │ ├── unread-aggregator.ts # Pure aggregateUnread: byGroup/byTab/byPin counts from notification list │ ├── hotkey-registry.ts # Pure action registry shared by ⌘K palette + ⌘/ overlay: buildActions/filterActions/groupForOverlay (effects injected by app.tsx). See docs/tasks/done/058 │ ├── find-bar.ts # Pure in-page find reducer: open/close/setQuery/setTotal/next-prev(wrap) + counterLabel (t001) + │ ├── push-notification.ts # Pure E1: revocation-proof SW push handler (t095). buildNotificationContent(data) → {title, options}; always renders (fallback on parse-fail) + │ ├── push-revalidate.ts # Pure E1b: once-per-foreground subscription re-validation gate (t095, ADR-0012). createPushRevalidateGate() → {shouldRevalidateNow(visible)} │ ├── cdp-web-transport.ts # Web build: thin assembler wiring Downlink + Uplink + REST bridge into window.cdp │ ├── downlink-dispatcher.ts # Web build: Downlink seam (one WS/SSE source) + Dispatcher (decode→fan-out→toast-once) │ ├── uplink-router.ts # Web build: Uplink seam (WS/stream/POST adapters) + ready-transport router diff --git a/CONTEXT.md b/CONTEXT.md index 8722095..5d0941d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,6 +76,14 @@ _Avoid_: mute list, blocklist, filter. A per-device, per-source suppression of notification *interruptions* (push, foreground toast, badge bump) without removing entries from the Inbox or bell list — muted entries still appear, dimmed. Stored in server ui-state under `notifMutes_` (a set of mute keys, survives the iPad PWA's localStorage wipe). The **mute key** (`muteKey`, `core/notif-mutes.js` / `src/lib/notif-mutes.ts`) unifies both axes: a Slack entry keys by its merged `groupKey` (`slack:{groupId}`, per workspace), every other adapter by `adapter` name (per service). Complementary to but distinct from **Channel Exclude** (which removes Slack channels from capture globally, not per-device, and hides them everywhere). See ADR-0013. _Avoid_: global mute, notification filter, blocklist. +**Web Push Subscription**: +A per-device push registration (endpoint URL + `auth`/`p256dh` keys) the browser issues via `pushManager.subscribe` and the server fans out to in `sendPushToAll`. Persisted in `web-push-subs.json`; each record carries a `deviceId` (t093) for per-device gating. The **endpoint** is the only identity that survives an iOS script-storage wipe (which clears localStorage, IndexedDB, cookies, and Cache *together*), so the server treats it as the stable key and reconciles a wiped device's `deviceId` by endpoint on (re)subscribe (t095). iOS 16.4+ standalone-PWA only. +_Avoid_: push token, device token, registration. + +**userVisibleOnly revocation**: +The WebKit rule that every `push` event on an iOS PWA *must* end in a visible notification (`showNotification`); a handler that finishes without one violates the `userVisibleOnly` promise and WebKit revokes the **Web Push Subscription** — with no documented grace count and, on iOS, no recovery (`pushsubscriptionchange` never fires there). The service worker therefore always shows something (the real notification or a generic fallback) and the app re-validates the subscription on foreground (t095). +_Avoid_: silent push (iOS forbids it for PWAs). + **Phone Shell**: A distinct layout mode for narrow viewports (reactive `matchMedia` width gate — not pointer-coarseness, not a `caps` flag) where the **Inbox** is the root view and the screencast canvas is a destination reached from a notification or the tab list, not home. The wide layout (sidebar + toolbar + canvas) is untouched above the breakpoint. _Avoid_: mobile mode, responsive layout, compact view. diff --git a/core/push-send-options.js b/core/push-send-options.js new file mode 100644 index 0000000..ec27f5d --- /dev/null +++ b/core/push-send-options.js @@ -0,0 +1,9 @@ +// Pure push notification send options: high urgency for timely delivery on battery-conscious devices, +// 1800s TTL so undeliverable notifications don't linger and resurface stale. + +export function pushSendOptions() { + return { + urgency: "high", + TTL: 1800, + } +} diff --git a/core/push-send-options.test.mjs b/core/push-send-options.test.mjs new file mode 100644 index 0000000..20b5cfe --- /dev/null +++ b/core/push-send-options.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" +import { pushSendOptions } from "./push-send-options.js" + +describe("pushSendOptions", () => { + it("returns urgency high and TTL 1800", () => { + const options = pushSendOptions() + expect(options.urgency).toBe("high") + expect(options.TTL).toBe(1800) + }) + + it("only includes urgency and TTL (no contentEncoding)", () => { + const options = pushSendOptions() + expect(Object.keys(options).sort()).toEqual(["TTL", "urgency"]) + }) + + it("returns the same values on multiple calls (deterministic)", () => { + const opt1 = pushSendOptions() + const opt2 = pushSendOptions() + expect(opt1).toEqual(opt2) + }) +}) diff --git a/core/push-subscriptions.js b/core/push-subscriptions.js new file mode 100644 index 0000000..0dab147 --- /dev/null +++ b/core/push-subscriptions.js @@ -0,0 +1,18 @@ +// Pure subscription reconciliation: server-authoritative deviceId keyed by endpoint. +// After a storage wipe, the same endpoint recovers its prior deviceId and per-device prefs. + +import { randomUUID } from "crypto" + +export function reconcileDeviceId(existingSubs, incoming) { + // Find if endpoint already exists + const existing = existingSubs.find((sub) => sub.endpoint === incoming.endpoint) + + if (existing && existing.deviceId) { + // Endpoint match wins; ignore any cached deviceId from the client + return { deviceId: existing.deviceId, isNew: false } + } + + // New endpoint — generate a fresh UUID v4 + const deviceId = randomUUID() + return { deviceId, isNew: true } +} diff --git a/core/push-subscriptions.test.mjs b/core/push-subscriptions.test.mjs new file mode 100644 index 0000000..6015781 --- /dev/null +++ b/core/push-subscriptions.test.mjs @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest" +import { reconcileDeviceId } from "./push-subscriptions.js" + +describe("reconcileDeviceId", () => { + it("returns a new deviceId when the subscription is new (endpoint not in store)", () => { + const existingSubs = [] + const incoming = { endpoint: "https://push.example.com/api/v1/sub1" } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).toBeTruthy() + expect(result.isNew).toBe(true) + expect(result.deviceId.length).toBeGreaterThan(0) + }) + + it("reuses existing deviceId when endpoint matches", () => { + const existing = { + endpoint: "https://push.example.com/api/v1/sub1", + deviceId: "device-uuid-123", + } + const existingSubs = [existing] + const incoming = { endpoint: "https://push.example.com/api/v1/sub1" } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).toBe("device-uuid-123") + expect(result.isNew).toBe(false) + }) + + it("generates a new id if incoming has a cached id that conflicts with endpoint binding", () => { + const existing = { + endpoint: "https://push.example.com/api/v1/sub1", + deviceId: "stored-uuid-123", + } + const existingSubs = [existing] + const incoming = { + endpoint: "https://push.example.com/api/v1/sub1", + deviceId: "different-cached-id", + } + + const result = reconcileDeviceId(existingSubs, incoming) + + // Endpoint match wins; incoming cached id is ignored + expect(result.deviceId).toBe("stored-uuid-123") + expect(result.isNew).toBe(false) + }) + + it("adds a new sub record for a new endpoint without duplicates", () => { + const sub1 = { + endpoint: "https://push.example.com/api/v1/sub1", + deviceId: "device-1", + } + const existingSubs = [sub1] + const incoming = { endpoint: "https://push.example.com/api/v1/sub2" } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).not.toBe("device-1") + expect(result.isNew).toBe(true) + }) + + it("handles multiple existing subscriptions and picks the matching one", () => { + const existing1 = { + endpoint: "https://push.example.com/api/v1/sub1", + deviceId: "device-1", + } + const existing2 = { + endpoint: "https://push.example.com/api/v1/sub2", + deviceId: "device-2", + } + const existing3 = { + endpoint: "https://push.example.com/api/v1/sub3", + deviceId: "device-3", + } + const existingSubs = [existing1, existing2, existing3] + const incoming = { endpoint: "https://push.example.com/api/v1/sub2" } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).toBe("device-2") + expect(result.isNew).toBe(false) + }) + + it("mints a valid UUID v4 for new subscriptions", () => { + const existingSubs = [] + const incoming = { endpoint: "https://push.example.com/api/v1/new" } + + const result = reconcileDeviceId(existingSubs, incoming) + + // Basic UUID v4 validation + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + expect(result.deviceId).toMatch(uuidRegex) + }) + + it("is idempotent for the same endpoint", () => { + const existingSubs = [] + const incoming = { endpoint: "https://push.example.com/api/v1/sub1" } + + const result1 = reconcileDeviceId(existingSubs, incoming) + // Simulate storing the result + const stored = [{ endpoint: incoming.endpoint, deviceId: result1.deviceId }] + const result2 = reconcileDeviceId(stored, incoming) + + expect(result2.deviceId).toBe(result1.deviceId) + expect(result2.isNew).toBe(false) + }) + + it("does not modify the input arrays", () => { + const existingSubs = [ + { endpoint: "https://push.example.com/api/v1/sub1", deviceId: "device-1" }, + ] + const existingSubsClone = JSON.parse(JSON.stringify(existingSubs)) + const incoming = { endpoint: "https://push.example.com/api/v1/sub2" } + + reconcileDeviceId(existingSubs, incoming) + + expect(existingSubs).toEqual(existingSubsClone) + }) +}) diff --git a/docs/adr/0014-endpoint-reconciled-per-device-push-identity.md b/docs/adr/0014-endpoint-reconciled-per-device-push-identity.md new file mode 100644 index 0000000..44fc51e --- /dev/null +++ b/docs/adr/0014-endpoint-reconciled-per-device-push-identity.md @@ -0,0 +1,57 @@ +# 0014 — Endpoint-reconciled per-device push identity + +**Date:** 2026-06-20 +**Status:** Accepted +**Deciders:** t095 (E0) + +## Context + +Web Push on iOS PWA (16.4+) requires stable per-device identity to survive storage eviction: + +- **The problem:** localStorage, IndexedDB, cookies, Cache API, and SW registration are evicted together when iOS needs storage (or on install/uninstall). A locally-generated `deviceId` cached in localStorage orphans the push subscription binding and forgets per-device prefs (mute keys, master on/off) on the next app launch after a wipe. + +- **The storage-layer contract:** The push endpoint URL (from `pushManager.subscribe()`) is the **only** identity that outlives storage eviction, because: + 1. The browser's push manager remembers which endpoints are subscribed (the Push Notification API state is OS-level, not script-storage). + 2. A re-subscribe to the same endpoint (same keys) is indistinguishable from the original. + 3. The endpoint is public (sent to the server on every subscribe POST). + +- **The per-device state problem (t093):** Per-device settings (master on/off, mute keys) are keyed by `deviceId` in ui-state (`notificationsEnabled_`, `notifMutes_`, `webPush_`). A lost or regenerated `deviceId` severs the link to these prefs, forcing the user to re-configure after a wipe. + +## Decision + +**The server becomes the authoritative source of `deviceId`, reconciled by push subscription endpoint.** + +Flow: +1. Client calls `POST /api/notifications/subscribe` with only the `endpoint` (not `deviceId`). +2. Server checks: does this endpoint already exist in `pushSubs`? + - **Yes:** return its stored `deviceId`; endpoint match wins (ignore any cached client-sent id). + - **No:** generate a new UUID v4, store it with the endpoint, return it. +3. Client receives the reconciled `deviceId` and adopts it as the single source for device-keyed ui-state keys. +4. Client stores the returned `deviceId` in localStorage as a cosmetic cache (for immediate reads before server round-trip), but trusts the server's reconciled id on every subscribe. + +### Why endpoint-keyed, not hash-keyed + +Alternative considered: hash the endpoint (e.g., sha256 base64 first 16 chars) and use that as the deviceId. + +- **Tradeoff:** A hash is deterministic (survives reload) but loses information (can't inspect logs) and requires pre-hashing on client to match the server's key (another point of skew). A UUID is opaque and paired with the endpoint in the record, so logs are greppable and the binding is explicit. +- **Decision:** UUIDs + explicit endpoint records are clearer for debugging and testing; the server is already reconciling anyway. + +## Consequences + +### Positive + +- After a storage wipe + re-subscribe to the same endpoint, the device recovers its prior `deviceId` (and thus prior mutes/master). +- No device can accidentally double-subscribe on the same endpoint (server dedupes by endpoint, not by `deviceId`). +- The subscription record is the source of truth; client-side caches can drift without breaking anything (only used for immediate UI reads). +- Per-device prefs (t093) survive the lifecycle naturally without special migration logic. + +### Negative + +- If the push endpoint changes (e.g., a browser re-generates it), a new `deviceId` is minted and the device gets a fresh (opt-out) prefs set. This is correct but invisible to the user — they'll see all notifications until they reconfigure (rare in practice; browsers are sticky with endpoints). +- The endpoint URL is logged server-side and persisted to disk (the `web-push-subs.json` file). If that file is exposed, the endpoints (but not the credentials) leak. Mitigation: endpoints are public by definition (sent on subscribe); the real secret is the `keys.auth` field, which is kept in the file too but never logged. + +## Links + +- **ADR-0013:** Per-device delivery gates (t093); this ADR extends it by making `deviceId` server-authoritative. +- **Task t095:** Harden Web Push delivery reliability (E0 implementation). +- **Test:** `test/e2e/server.e2e.test.ts` — push subscription reconcile e2e keystone tests the endpoint-matching logic end-to-end. diff --git a/docs/memories/learnings.md b/docs/memories/learnings.md index ee3c635..89e6a22 100644 --- a/docs/memories/learnings.md +++ b/docs/memories/learnings.md @@ -6,6 +6,8 @@ Things discovered in practice that weren't obvious from the spec. Surprises, got --- +**2026-06-20** — On an **iOS PWA (incl. iOS 26)** Web Push notifications can show **only the installed app's icon** (manifest / apple-touch-icon). The `icon` and `image` fields of `showNotification()` are **ignored** by iOS/macOS Safari (confirmed on Apple's dev forums and across vendors), so there is no per-notification or per-source icon — a Slack, Teams, and Outlook ping all render the same app icon, indistinguishable in the banner. The "big avatar + small app-icon in the corner" look that native messaging apps have is **Communication Notifications** (`INSendMessageIntent` donation + a Notification Service Extension + the Communication Notifications capability) — **native-app only, explicitly not available to web push**. PWAs also can't swap their home-screen icon at runtime (no web equivalent of `setAlternateIconName`), so you can't fake it that way either. The **only** dynamic banner signals an iOS PWA gets are the **title**, the **body**, and the **app-badge number** (`navigator.setAppBadge`, already shipped t080/t093). Practical consequence: to convey *which source* sent a push on iOS, it must go in the **title text** — the per-adapter favicons (t088/t089, `adapter-icon.tsx`) only render **in-app** (Inbox/bell/toast), never in the OS banner. Don't spend effort trying to get a custom/dynamic push icon on iOS; it isn't possible. + **2026-05-29** — A clicked Electron **OS notification** silently did nothing (banner showed, click → no tab focus, no deep-open) because the `Notification` was a local `const` with no retained reference: V8 garbage-collects the JS `Notification` object, and a collected Notification never delivers its `click` event. Fix: hold every shown Notification in a module-level `Set` (`liveNotifications` in `main.js`) until it is clicked or closed. The diagnosis method that nailed it after several wrong guesses: fire a **synthetic `cdp:notification-activate` from main straight into the renderer** (a temporary env-gated hook), bypassing the OS click — the remote Teams conversation switched, proving the renderer / `chromeSend` / `openTeamsThread` path was fine and isolating the failure to OS-click delivery. Lesson: when an Electron notification click "does nothing", suspect Notification GC first; and when a multi-stage handler fails, trigger the downstream stage directly to bisect renderer-vs-transport-vs-OS rather than reasoning about the whole chain. **2026-05-29** — Changing the notification entry shape (t028 added `activate`/`groupKey`/`adapter`) silently broke Teams/Outlook **Web Push** deep-open while the in-app path kept working. Root cause: three *hand-picked field lists* along the push chain each re-projected the entry to the pre-t028 schema and were never extended — the server push payload (`web/server.mjs` `sendPushToAll`), the service worker's `options.data` (`public/sw.js`), and the renderer's serviceWorker-`message` entry reconstruction (`cdp-web-transport.ts`). TypeScript can't catch this: the chain crosses process boundaries (server → SW → page) as untyped JSON, and `CdpNotification` even allowed the new fields as optional, so dropping them type-checked clean. Lesson: when a cross-boundary payload shape changes, grep for every manual `{ id, targetId, … }` re-projection of it — the SSE/in-app path that forwards the *whole* entry will mask the regression on the path that doesn't. Guarded now by a regression test asserting the SW-message path preserves `activate`. diff --git a/docs/tasks/done/095-harden-web-push-delivery-reliability.md b/docs/tasks/done/095-harden-web-push-delivery-reliability.md new file mode 100644 index 0000000..7f465e6 --- /dev/null +++ b/docs/tasks/done/095-harden-web-push-delivery-reliability.md @@ -0,0 +1,144 @@ +# 095 — harden web push delivery reliability + +- **Status:** done +- **Mode:** AFK (the only HITL bits are device-only iOS confirmations, carved out as **non-blocking** — see Test plan) +- **Estimate:** 2d +- **Depends on:** none (builds on t066 subscribe, t080 push, t093 per-device delivery) +- **Blocks:** the deferred push roadmap (E3/E5/E7/E8 all assume a live, recoverable subscription — see Out of scope) + +## Goal + +Make the web-build Web Push subscription **stay alive, recoverable, and timely** on the daily-driver iPad PWA. Three coupled fixes ship together because they form one story — "a push subscription that survives the things iOS does to it": + +1. **E1 — revocation-proof the service worker.** `public/sw.js`'s `push` handler returns early (no `showNotification`) on a payload with no data or a JSON-parse failure. On iOS that is a `userVisibleOnly` violation, and WebKit revokes the subscription on violation — with **no documented grace count**. After this, every `push` event renders something (the real notification, or a generic "New message" fallback), so a bad payload can never put the subscription at risk. +2. **E0 — make per-device identity survive a storage wipe.** `deviceId` lives in `localStorage`, which is wiped on the iPad PWA (and IndexedDB/cookies/Cache are wiped in the *same* eviction, so they're no escape). A regenerated `deviceId` orphans the subscription binding and resets every t093 per-device pref (mutes, master). After this, `deviceId` is **server-authoritative, reconciled by the push subscription endpoint** (the only identity that can outlive a script-storage wipe); `localStorage` is a cosmetic cache. +3. **E1b — recover a dead subscription on app foreground.** `pushsubscriptionchange` never fires on iOS PWAs, so the SW's re-subscribe path (`sw.js`) is dead there; today re-validation only happens if the user opens Settings. After this, the app re-validates/re-subscribes on `visibilitychange` (foreground), the only recovery hook iOS leaves — so opening the PWA heals a silently-revoked subscription. + +Plus a delivery-tuning slice: the single `webpush.sendNotification` call sends with no `urgency`/`TTL`, so a triage ping can be battery-deferred and an undeliverable one lingers ~4 weeks and resurrects stale. After this it sends `urgency: "high"` + `TTL: 1800`. + +## Why now + +The web PWA is the priority surface and exists to triage notifications (ADR-0012). A silently-revoked or orphaned push subscription is the highest-severity failure the product has: the user believes they are covered and silently misses everything, with no signal and no self-recovery on iOS. Every deferred push improvement (conversation collapse, Topic header, clear-on-read, VAPID-env rotation) assumes a subscription that stays alive — so this reliability core lands first. + +> **Scope note (conscious decision, grilling 2026-06-20):** bundling E1 + E0 + E1b exceeds the half-day one-session cap (~2d). Accepted by the user. Land in dependency order with commit checkpoints — **E1 first** (the urgent P0, `sw.js`-only), then **E0** (identity), then **E1b** (recovery, needs E0). If a session nears compaction, split at those seams rather than carrying a half-done cluster. + +## Acceptance criteria + +- [ ] **E1:** the SW `push` handler **always** calls `showNotification` — real notification for a valid payload; generic fallback (title "New message", empty body, fixed `tag: "cdp-fallback"`) for `!e.data`, a `e.data.json()` throw, or any processing error. No code path returns without showing. +- [ ] **E1:** notification-content shaping (title + options incl. the fallback branch) is a pure, unit-tested `buildNotificationContent(data)` in `src/lib/`, mirrored into `public/sw.js` (the `sw-cache-name.ts` pattern — a static SW can't import the module). +- [ ] **E0:** `deviceId` is server-authoritative. `POST /api/notifications/subscribe` reconciles by endpoint — an incoming subscription whose `endpoint` matches a stored record reuses that record's `deviceId`; otherwise it mints one — and **returns the reconciled `deviceId`**. The renderer adopts the returned id as the single source for all device-keyed ui-state (`notifMutes_`, `notificationsEnabled_`, `webPush_`); `localStorage` is only a cache. +- [ ] **E0:** the reconcile decision is a pure, unit-tested helper in `core/` (server side); a wiped client that re-subscribes with the same endpoint recovers its prior `deviceId` (and thus its prior mutes/master), and no duplicate sub record accrues for the same endpoint. +- [ ] **E1b:** the app re-validates/re-subscribes on `visibilitychange` → visible, gated **once-per-foreground** + debounced, via a pure, unit-tested "should-revalidate-now" decision helper in `src/lib/`. The existing settings-open call stays; the dead `push-subscription-change` path is left as-is (harmless, still correct on non-iOS). +- [ ] **E2-tuning:** `webpush.sendNotification` is called with options from a pure, tested `pushSendOptions()` returning `{ urgency: "high", TTL: 1800 }`; both entry pushes and Slack health alerts inherit it (one call site). No `contentEncoding` added (`aes128gcm` is already the default — a no-op). +- [ ] **AFK keystone:** an e2e test (the `test/e2e/` fake-CDP + `web/server.mjs` harness, isolated `SUBS_PATH`) proves the reconcile end-to-end: subscribe with endpoint E1 → `{deviceId:D1}`; re-subscribe E1 → same `D1` + no duplicate sub record; subscribe E2 → a different id. +- [ ] **E4-guard:** a test asserts the Slack health-alert payload's `muteKey` equals `slack:{groupId}` (locks the t093 stamping that is correct today). +- [ ] No behavior change to `setAppBadge` mirroring, the deep-route `data` payload, `notificationclick`, or the push-content threat model (content still rides RFC 8291-encrypted; no app-layer E2E seal added — that's an E6 concern). + +## Test plan + +**AFK posture:** every gate below runs headlessly (`pnpm test`, `pnpm test:e2e`, `pnpm typecheck`, `pnpm build`, `node --check`, `pnpm web` boot). The genuinely device-only iOS behaviors are isolated in a **non-blocking** post-merge section so an AFK agent can build, verify, and close on green automated gates. Residual risk is accepted (see Notes): the iOS guarantees are asserted by pure logic + e2e + spec, with the changed SW/renderer parts being thin glue that mirrors tested helpers. + +### Layer 1 — Pure logic (TDD) — AFK, `pnpm test` + +- [ ] `src/lib/` — `buildNotificationContent` returns the real `{title, options}` for a valid payload (`tag = data.id`, deep-route `data`, icon/badge/timestamp) and the fallback `{title:"New message", options:{tag:"cdp-fallback"}}` for `null`/`undefined`/parse-failure (covers the two `sw.js` early-return branches). +- [ ] `core/` — `reconcileDeviceId(existingSubs, { endpoint, deviceId? })`: matching endpoint → reuse stored `deviceId`; new endpoint → mint; an incoming cached `deviceId` that conflicts with the endpoint's stored binding → endpoint wins. Idempotent; no duplicate record per endpoint. +- [ ] `src/lib/` — the once-per-foreground/debounce decision (visible + not-already-revalidated-this-foreground + push-enabled → revalidate; hidden→visible resets the gate). **Distinct from** the new `usePullToRefresh`/`refreshNotifications` (that re-fetches the notif *list*, not the push *subscription* — don't conflate). +- [ ] `core/` — `pushSendOptions()` returns `{ urgency:"high", TTL:1800 }` (makes the header value a tested source, not a buried literal). +- [ ] `core/notif-mutes` (or server test) — `muteKey` of `{adapter:"slack", groupKey:"slack:{groupId}"}` resolves to `slack:{groupId}` (E4 regression guard). + +### Layer 2 — Automated integration (AFK, `pnpm test:e2e` + boot checks) + +- [ ] **e2e reconcile (keystone):** in `test/e2e/server.e2e.test.ts` via `startWebServer` (isolated `SUBS_PATH`) — `POST /api/notifications/subscribe` returns `{ deviceId }`; same endpoint twice → same id + single sub record; a second endpoint → a distinct id. Proves E0 end-to-end with no device. +- [ ] `node --check web/server.mjs`; `pnpm web` boots cleanly against the fake CDP host; existing e2e suite (`server.e2e.test.ts`, `resilience.e2e.test.ts`) still green. + +### Layer 3 — Visual review + +- [ ] n/a — no renderer UI changes (the push toggle / settings card are untouched). The fallback banner is an OS notification. + +### Post-merge device confirmation (HITL — NON-BLOCKING, does not gate AFK close) + +Logged for the next real-device session; the automated gates above are sufficient to close the task AFK. + +- [ ] Real installed iOS PWA: a malformed/decrypt-failed push shows the generic banner and does **not** revoke the subscription. +- [ ] Storage-wipe recovery: after a wipe + foreground re-subscribe with the same endpoint, prior mutes/master are restored (server reconciled the same `deviceId`). +- [ ] Foreground re-validate fires once on `visibilitychange`; `urgency:"high"` notifications arrive promptly. + +## Design notes + +- **Contracts changed:** + - `POST /api/notifications/subscribe` response — was `void`, now `{ deviceId }` (the reconciled id). The renderer's `subscribePush`/`reValidateSubscription` adopt it; `getOrCreateDeviceId` becomes "adopt the server's id, cache locally" rather than "mint locally, trust forever." + - `webpush.sendNotification(sub, data)` — gains an options object `{ urgency: "high", TTL: 1800 }`. + - SW `push` handler invariant — **must** end in `showNotification` on every path. +- **New modules:** + - `src/lib/push-notification.ts` — pure `buildNotificationContent` + the fallback constant (lets the fallback logic be TDD'd; SW mirrors it like `sw-cache-name.ts`). + - `core/push-subscriptions.js` — pure `reconcileDeviceId(existingSubs, incoming)` (server-side, so `server.mjs` uses the CJS copy and it's unit-tested). + - `src/lib/.ts` — pure once-per-foreground gate (small; could fold into an existing module if it stays tiny). +- **New ADR needed?** Yes — **ADR-0014 "endpoint-reconciled per-device push identity"**, but **deferred to E0 implementation** (grilling decision): write it once the reconcile mechanics are concrete. It extends ADR-0013 (per-device delivery): `deviceId` server-authoritative + reconciled by endpoint *because* localStorage/IndexedDB are wiped together on iOS and the push endpoint is the only stable identity; records the tradeoff vs re-keying all per-device ui-state by an endpoint hash, and the `userVisibleOnly`-revocation hardening rationale. + +```ts +// E1 — pure, mirrored into public/sw.js +function buildNotificationContent(data: PushData | null | undefined): + { title: string; options: NotificationOptions } +// valid -> { title: data.title ?? "CDP Browser", options: { body, icon, badge, tag: data.id, timestamp, data } } +// absent -> { title: "New message", options: { tag: "cdp-fallback", badge } } + +// E0 — pure, server side +function reconcileDeviceId( + existingSubs: { endpoint: string; deviceId?: string }[], + incoming: { endpoint: string; deviceId?: string }, +): { deviceId: string; isNew: boolean } // endpoint match wins over the incoming cached id +``` + +## Out of scope + +Deferred push roadmap (each its own task; E0/E1b are NO LONGER deferred — they're in this task): + +- **E3 — collapse a conversation** (tag by `groupKey`): a **product-taste decision** — iOS tag-replace is silent (no `renotify`), so 2nd..Nth messages in a thread wouldn't re-alert. Needs sign-off; must not ride a hardening task. +- **E5 — `Topic` header** for pre-delivery collapse of queued same-conversation pings (sha256(groupKey) base64url ≤32 chars). +- **E7 — clear-on-read banners + foreground `setAppBadge` self-heal** (also fixes the master-off badge-staleness path). +- **E8 — VAPID keys env-only** (fail loud on the baked default) + real subject; sequence after this task (a rotation invalidates all subs, and recovery now exists via E1b). +- **E6 — Declarative Web Push** (iOS 18.4+). Real E2E tension lives **here**: the OS renders a declarative payload without running the SW, so the app's AES-GCM E2E layer can't decrypt it → declarative content can't be app-E2E-sealed. Needs a policy (content-free under E2E?) + an 18.4 device. +- **E9 — Electron notification parity** (route OS toast + dock badge through `core/notif-mutes`, add id/icon/subtitle). +- **E10 — server-side triage ladder** (mention-only scope, quiet hours, snooze, keywords) — per-device ui-state like t093. + +Also out of scope: any `localStorage` persistence for durable prefs; cross-device read-state sync (impossible silently on iOS — a silent push would revoke the sub); changing the push-content threat model. + +## Definition of Done + +**AFK completion gates — all required to close (no device needed):** + +- [ ] Layer 1 tests written and green (`buildNotificationContent`, `reconcileDeviceId`, the foreground-revalidate gate, `pushSendOptions`, the `muteKey` guard) +- [ ] Layer 2 green: the e2e reconcile keystone test passes; `node --check web/server.mjs`; `pnpm web` boots against the fake CDP host +- [ ] `pnpm test` green; `pnpm test:e2e` green +- [ ] `pnpm typecheck` clean; `pnpm check:changed` clean (Biome on the diff); `pnpm build` clean +- [ ] CLAUDE.md (web-build push bullet) + `src/lib/CLAUDE.md` (new modules) updated +- [ ] CONTEXT.md gains *Web Push Subscription* + *userVisibleOnly revocation* (done during grilling) +- [ ] **ADR-0014 written** (endpoint-reconciled per-device push identity) — authored during E0 implementation +- [ ] No commented-out code, no stray `console.log`, no AI attribution +- [ ] Task closed: status → done, moved to `docs/tasks/done/`, `t095` in branch + commit + +**Non-blocking (do NOT gate AFK close):** the post-merge device confirmation checklist (real iOS PWA no-revocation, storage-wipe recovery, foreground re-validate) — run on the next device session and note results in the closed task. + +## Notes + +Origin: a notification-pipeline investigation + internet deep-research pass (Electron + PWA push), synthesized and adversarially reviewed, then grilled (`/grill-with-docs`, 2026-06-20). + +**Verified findings:** +- `sw.js` returns without `showNotification` on `!e.data` and on `e.data.json()` throw → iOS `userVisibleOnly` violation → subscription revoked, **no documented grace count**, no recovery (`pushsubscriptionchange` is dead on iOS PWAs). +- `webpush.sendNotification` is called with no options. `aes128gcm` is already the default encoding (the "add contentEncoding" idea was a no-op — cut). +- The Slack health-alert **already** stamps `adapter`/`groupKey` (t093) — the originally-proposed "muteKey resolves undefined" bug **does not exist**; only a regression test was salvaged (E4). +- There is **one** `webpush.sendNotification` site (`sendPushToAll`); both entry pushes and health alerts route through it, so the header fix lands once. +- **Plan re-checked against `main` @ `83e6397`** (phone keyboard-follow / nav-stack / pull-to-refresh + cmd-shortcut fix): `sw.js`, `server.mjs`, `core/*` untouched (E1/E2/E4 intact); `cdp-web-transport.ts` change was E2E-bootstrap mount-safety only (`getOrCreateDeviceId`/`subscribePush` unchanged → E0 intact); `settings-dialog.tsx` change was safe-area padding only (`reValidateSubscription` unchanged → E1b intact). `app.tsx` now has a phone nav stack + `usePullToRefresh`/`refreshNotifications`, but **no `visibilitychange` handler** — E1b adds its own foreground hook and stays distinct from pull-to-refresh (which re-fetches the notif *list*, not the *subscription*). +- iOS evicts **all** script-writable storage together (localStorage, IndexedDB, cookies, Cache, SW registration). Installed home-screen PWAs are *exempt* from the 7-day cap per spec — but the user has empirically observed localStorage resetting on the iPad PWA (MEMORY `localstorage-resets-in-pwa`, 2026-05-30), so E0 does not bet on localStorage surviving. Sources: [WebKit 7-day cap](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/), [Search Engine Land summary](https://searchengineland.com/what-safaris-7-day-cap-on-script-writeable-storage-means-for-pwa-developers-332519), [MagicBell PWA iOS guide](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide). + +**Grilling decisions (2026-06-20):** +1. Scope = bundle E1 + E0 + E1b + E2-tuning + E4-guard (the full reliability cluster), accepting the >1-session size with commit checkpoints in dependency order (Q1). +2. E0 = server-authoritative `deviceId` reconciled by subscription endpoint; localStorage is a cache; subscribe returns the id; renderer adopts it (Q2). +3. E1b trigger = app-foreground `visibilitychange`, once-per-foreground + debounced, pure decision helper; keep settings-open (Q3). +4. E2E ↔ push content = no change this task; the real tension is declarative-push-only → flagged on E6. +5. TTL = 1800s; fallback = "New message"/empty/`cdp-fallback`; extraction = `src/lib/push-notification.ts` mirrored into `sw.js`, `urgency`/`TTL` inline in `sendPushToAll`. +6. ADR-0014 deferred to E0 implementation (Q4). + +--- + +_When task status flips to `done`, move this file to `done/`._ diff --git a/public/sw.js b/public/sw.js index d7a050c..a4150d4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -50,47 +50,49 @@ self.addEventListener("fetch", (e) => { ) }) -// Web Push handler — fires whenever the push service delivers a notification to this -// device, including when the PWA is backgrounded or the screen is locked. iOS 16.4+ -// PWAs only; the payload mirrors what the server's `sendPushToAll` sends. -self.addEventListener("push", (e) => { - if (!e.data) return - let data - try { - data = e.data.json() - } catch { - return +// Build notification content: mirrors src/lib/push-notification.ts for static SW, +// ensuring the fallback logic for parse errors is centralized and tested (E1). +const NOTIFICATION_FALLBACK_TAG = "cdp-fallback" +function buildNotificationContent(data) { + if (!data || typeof data !== "object") { + return { + title: "New message", + options: { + body: "", + badge: "/icons/icon-192.png", + tag: NOTIFICATION_FALLBACK_TAG, + data: {}, + }, + } } const title = data.title || "CDP Browser" const options = { body: data.body || "", icon: data.icon || "/icons/icon-192.png", badge: "/icons/icon-192.png", - tag: data.id || undefined, // collapses repeat notifications with same id + tag: data.id || undefined, timestamp: data.ts || Date.now(), - data: { - id: data.id, - source: data.source, - title: data.title, - body: data.body, - targetId: data.targetId, - targetUrl: data.targetUrl, - targetEntity: data.targetEntity, - adapter: data.adapter, - groupKey: data.groupKey, - activate: data.activate, - ts: data.ts, - // Conversation identity for the reader deep-route + composer (t080). - channelId: data.channelId, - slackKind: data.slackKind, - slackTs: data.slackTs, - slackThreadTs: data.slackThreadTs, - }, + data: data, + } + return { title, options } +} + +// Web Push handler — fires whenever the push service delivers a notification to this +// device, including when the PWA is backgrounded or the screen is locked. iOS 16.4+ +// PWAs only; the payload mirrors what the server's `sendPushToAll` sends. ALWAYS +// calls showNotification to prevent userVisibleOnly revocation (E1). +self.addEventListener("push", (e) => { + let data + try { + data = e.data?.json() || null + } catch { + data = null } + const { title, options } = buildNotificationContent(data) const work = [self.registration.showNotification(title, options)] // Home-screen badge mirror (t080): the server stamps the unread count on every push, // so the icon is glanceable without opening the app. Feature-detected (iOS 16.4+). - if (typeof data.unread === "number" && navigator.setAppBadge) { + if (typeof data?.unread === "number" && navigator.setAppBadge) { work.push( (data.unread > 0 ? navigator.setAppBadge(data.unread) diff --git a/src/app.tsx b/src/app.tsx index f4f8b63..b008fa5 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,6 +36,7 @@ import { } from "@/lib/notification-activation" import { threadKey } from "@/lib/notifications-view" import { dropDeadLinks, pinForTarget, resolvePinLink } from "@/lib/pins" +import { createPushRevalidateGate } from "@/lib/push-revalidate" import { notifIdFromSearch, resolvePushEntry, stripNotifParam } from "@/lib/push-route" import { shouldApplyAdaptive } from "@/lib/shell-mode" import { @@ -247,6 +248,22 @@ export default function App() { window.addEventListener("popstate", onPop) return () => window.removeEventListener("popstate", onPop) }, []) + // E1b: Re-validate push subscription on app foreground (iOS PWA recovery path). + // pushsubscriptionchange never fires on iOS PWAs, so the only recovery hook is + // visibilitychange. Once-per-foreground gate prevents spam; hidden→visible resets + // the gate for the next foreground. The settings dialog's reValidateSubscription + // handles the actual subscription refresh (deferred: t095-note in task file). + useEffect(() => { + const pushRevalidateGate = createPushRevalidateGate() + const onVisibilityChange = () => { + const visible = document.visibilityState === "visible" + // Gate fires once per foreground; reset on hidden→visible transition. + pushRevalidateGate.shouldRevalidateNow(visible) + // TODO(t095-future): call reValidateSubscription when it's on the bridge + } + document.addEventListener("visibilitychange", onVisibilityChange) + return () => document.removeEventListener("visibilitychange", onVisibilityChange) + }, []) // Pull-to-refresh action for the phone Inbox (UX): re-fetch the swept notification list. // A yank-to-refresh is exactly when the link may be down — fail quietly (the hook's // `finally` still clears the spinner) rather than throw an unhandled rejection. diff --git a/src/lib/cdp-web-transport.ts b/src/lib/cdp-web-transport.ts index 49c153c..dc0821f 100644 --- a/src/lib/cdp-web-transport.ts +++ b/src/lib/cdp-web-transport.ts @@ -803,14 +803,26 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge // The three device-keyed prefs (`_`) are the per-device delivery seam; the // server reads the same keys for the push gate. Clicking re-focuses and routes through the // same notification-activate listeners the renderer registers. - const deviceId = getOrCreateDeviceId() - const webPushKey = `webPush_${deviceId}` - const masterKey = `notificationsEnabled_${deviceId}` - const mutesKey = `notifMutes_${deviceId}` + // E0: deviceId is mutable; on subscription the server returns its reconciled id (by endpoint) + // and the renderer adopts it, updating localStorage + ui-state keys as needed. + let deviceId = getOrCreateDeviceId() + let webPushKey = `webPush_${deviceId}` + let masterKey = `notificationsEnabled_${deviceId}` + let mutesKey = `notifMutes_${deviceId}` let webPush = false // Per-device master defaults on (opt-out — a new device gets everything, t093). let notifMaster = true let notifMutes: string[] = [] + const setDeviceId = (newId: string) => { + if (newId === deviceId) return + deviceId = newId + webPushKey = `webPush_${deviceId}` + masterKey = `notificationsEnabled_${deviceId}` + mutesKey = `notifMutes_${deviceId}` + try { + localStorage?.setItem("cdp_device_id", deviceId) + } catch {} + } function maybeToast(entry: CdpNotification) { if (!notifMaster) return // device master off — no foreground toast @@ -1296,10 +1308,15 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge const r = await rest.getJson("/api/notifications/vapid-public-key") return r.key as string }, - // Stamp this device's id on the subscription so the server can read its per-device - // master + mutes from ui-state and gate/stamp each push per device (t093). - subscribePush: (sub) => - rest.postJson("/api/notifications/subscribe", { ...(sub as object), deviceId }), + // E0: Subscribe without local deviceId; server reconciles by endpoint and returns the + // authoritative id. Renderer adopts it, updating localStorage + ui-state keys. + subscribePush: async (sub) => { + const result = (await rest.postJson("/api/notifications/subscribe", sub as object)) as { + deviceId: string + } + setDeviceId(result.deviceId) + return result + }, unsubscribePush: (endpoint) => rest.postJson("/api/notifications/unsubscribe", { endpoint }), // Transport-picker hooks (t019). The settings UI calls reconfigureInputTransport() // when the user toggles a mode; the badge reads getActiveTransport() and subscribes diff --git a/src/lib/push-notification.test.ts b/src/lib/push-notification.test.ts new file mode 100644 index 0000000..8f0215b --- /dev/null +++ b/src/lib/push-notification.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest" +import { buildNotificationContent, NOTIFICATION_FALLBACK_TAG } from "./push-notification" + +describe("buildNotificationContent", () => { + it("renders valid notification data with all fields", () => { + const data = { + id: "slack:team1:channel1:ts123", + title: "Alice", + body: "Hey there", + icon: "/icons/slack.png", + source: "slack", + targetId: "target1", + targetUrl: "https://slack.com/archives/C123", + adapter: "slack", + groupKey: "slack:team1", + activate: { type: "spa-link" as const, url: "/client/team1/channel1" }, + ts: 1718000000000, + unread: 3, + } + const result = buildNotificationContent(data) + expect(result.title).toBe("Alice") + expect(result.options.body).toBe("Hey there") + expect(result.options.tag).toBe("slack:team1:channel1:ts123") + expect(result.options.icon).toBe("/icons/slack.png") + expect(result.options.badge).toBe("/icons/icon-192.png") + expect((result.options as any).timestamp).toBe(1718000000000) + expect(result.options.data).toEqual(data) + }) + + it("falls back to 'CDP Browser' title when data.title is missing", () => { + const data = { + id: "notif1", + body: "Message", + source: "slack", + targetId: "t1", + } + const result = buildNotificationContent(data as any) + expect(result.title).toBe("CDP Browser") + expect(result.options.body).toBe("Message") + }) + + it("uses generic fallback notification for null data", () => { + const result = buildNotificationContent(null) + expect(result.title).toBe("New message") + expect(result.options.body).toBe("") + expect(result.options.tag).toBe(NOTIFICATION_FALLBACK_TAG) + expect(result.options.data).toEqual({}) + }) + + it("uses generic fallback notification for undefined data", () => { + const result = buildNotificationContent(undefined) + expect(result.title).toBe("New message") + expect(result.options.body).toBe("") + expect(result.options.tag).toBe(NOTIFICATION_FALLBACK_TAG) + }) + + it("accepts empty object and uses defaults for missing fields", () => { + const result = buildNotificationContent({} as any) + expect(result.title).toBe("CDP Browser") + expect(result.options.body).toBe("") + expect(result.options.tag).toBeUndefined() + }) + + it("preserves deep-route data (activate, targetId, channelId, etc.)", () => { + const data = { + id: "teams:123:456", + title: "Teams", + body: "Reply", + source: "teams", + targetId: "target1", + adapter: "teams", + activate: { type: "thread" as const, id: "msg123" }, + channelId: "ch123", + slackKind: "channel", + slackTs: "1234567890.000100", + slackThreadTs: "1234567890.000100", + ts: 1718000000000, + } + const result = buildNotificationContent(data as any) + expect(result.options.data).toEqual(data) + }) + + it("uses current timestamp when data.ts is missing", () => { + const before = Date.now() + const data = { + id: "notif1", + title: "Test", + body: "msg", + source: "slack", + targetId: "t1", + } + const result = buildNotificationContent(data as any) + const after = Date.now() + expect((result.options as any).timestamp).toBeGreaterThanOrEqual(before) + expect((result.options as any).timestamp).toBeLessThanOrEqual(after) + }) + + it("always includes badge for consistency across platforms", () => { + const data = { + id: "notif1", + title: "Test", + body: "msg", + source: "slack", + targetId: "t1", + } + const result = buildNotificationContent(data as any) + expect(result.options.badge).toBe("/icons/icon-192.png") + }) +}) diff --git a/src/lib/push-notification.ts b/src/lib/push-notification.ts new file mode 100644 index 0000000..6897f90 --- /dev/null +++ b/src/lib/push-notification.ts @@ -0,0 +1,32 @@ +export const NOTIFICATION_FALLBACK_TAG = "cdp-fallback" + +export interface PushNotificationContent { + title: string + options: NotificationOptions +} + +export function buildNotificationContent(data: any): PushNotificationContent { + if (!data || typeof data !== "object") { + return { + title: "New message", + options: { + body: "", + badge: "/icons/icon-192.png", + tag: NOTIFICATION_FALLBACK_TAG, + data: {}, + } as any, + } + } + + const title = data.title || "CDP Browser" + const options: NotificationOptions & { timestamp?: number } = { + body: data.body || "", + icon: data.icon || "/icons/icon-192.png", + badge: "/icons/icon-192.png", + tag: data.id || undefined, + timestamp: data.ts || Date.now(), + data: data, + } + + return { title, options } +} diff --git a/src/lib/push-revalidate.test.ts b/src/lib/push-revalidate.test.ts new file mode 100644 index 0000000..04529f5 --- /dev/null +++ b/src/lib/push-revalidate.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" +import { createPushRevalidateGate } from "./push-revalidate" + +describe("push revalidate gate", () => { + it("initially allows revalidation", () => { + const gate = createPushRevalidateGate() + expect(gate.shouldRevalidateNow(true)).toBe(true) + }) + + it("blocks revalidation after once-per-foreground has fired", () => { + const gate = createPushRevalidateGate() + expect(gate.shouldRevalidateNow(true)).toBe(true) + // Call it again without resetting (still visible) + expect(gate.shouldRevalidateNow(true)).toBe(false) + }) + + it("resets the gate when app goes hidden", () => { + const gate = createPushRevalidateGate() + expect(gate.shouldRevalidateNow(true)).toBe(true) + expect(gate.shouldRevalidateNow(true)).toBe(false) + // Now hide + gate.shouldRevalidateNow(false) + // Visible again — should fire once + expect(gate.shouldRevalidateNow(true)).toBe(true) + }) + + it("does not revalidate when app is hidden", () => { + const gate = createPushRevalidateGate() + expect(gate.shouldRevalidateNow(false)).toBe(false) + expect(gate.shouldRevalidateNow(false)).toBe(false) + }) + + it("tracks state across multiple hide/show cycles", () => { + const gate = createPushRevalidateGate() + // Cycle 1 + expect(gate.shouldRevalidateNow(true)).toBe(true) + expect(gate.shouldRevalidateNow(true)).toBe(false) + gate.shouldRevalidateNow(false) + // Cycle 2 + expect(gate.shouldRevalidateNow(true)).toBe(true) + expect(gate.shouldRevalidateNow(true)).toBe(false) + gate.shouldRevalidateNow(false) + // Cycle 3 + expect(gate.shouldRevalidateNow(true)).toBe(true) + }) + + it("returns false for hidden transitions in isolation", () => { + const gate = createPushRevalidateGate() + gate.shouldRevalidateNow(false) + expect(gate.shouldRevalidateNow(false)).toBe(false) + }) +}) diff --git a/src/lib/push-revalidate.ts b/src/lib/push-revalidate.ts new file mode 100644 index 0000000..7cafef1 --- /dev/null +++ b/src/lib/push-revalidate.ts @@ -0,0 +1,29 @@ +// Pure once-per-foreground gate for push subscription re-validation. +// On app visible, allow revalidation once; on hidden, reset for next foreground. + +export function createPushRevalidateGate() { + let isVisible = false + let revalidatedThisForeground = false + + return { + shouldRevalidateNow(visible: boolean): boolean { + const wasHidden = !isVisible && visible // transition hidden → visible + isVisible = visible + + if (wasHidden) { + revalidatedThisForeground = false + } + + if (!isVisible) { + return false + } + + if (revalidatedThisForeground) { + return false + } + + revalidatedThisForeground = true + return true + }, + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 57c59aa..56242ce 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -157,9 +157,10 @@ interface CdpBridge { }) => Promise<{ ok?: boolean; ts?: string; error?: string }> // Web Push (web build only — Electron has its own Notification API). // `getPushVapidKey` returns the server's VAPID public key for pushManager.subscribe. - // `subscribePush`/`unsubscribePush` POST the browser-issued subscription to the server. + // `subscribePush` POSTs the browser-issued subscription to the server and returns + // the server-reconciled deviceId (E0); `unsubscribePush` removes a subscription. getPushVapidKey?: () => Promise - subscribePush?: (subscription: PushSubscriptionJSON) => Promise + subscribePush?: (subscription: PushSubscriptionJSON) => Promise<{ deviceId: string }> unsubscribePush?: (endpoint: string) => Promise // Input transport picker (web build only — Electron uses IPC, no transport choice). // The settings UI calls reconfigureInputTransport() after writing the pref to diff --git a/test/e2e/server.e2e.test.ts b/test/e2e/server.e2e.test.ts index c886131..2e3557f 100644 --- a/test/e2e/server.e2e.test.ts +++ b/test/e2e/server.e2e.test.ts @@ -791,3 +791,85 @@ describe("group clear — remove notifications by id (t085)", () => { expect(Array.isArray(await res.json())).toBe(true) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +describe("push subscription reconcile (E0 — endpoint-keyed deviceId)", () => { + let fake: any + let server: any + + beforeEach(async () => { + fake = await startFakeCdpHost({ targets: DEFAULT_TARGETS }) + server = await startWebServer(fake) + }) + afterEach(async () => { + server.stop() + await fake.stop() + }) + + it("mints a new deviceId for a new subscription endpoint", async () => { + const res = await server.fetch("/api/notifications/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: "https://push.example.com/api/v1/sub1", + keys: { p256dh: "key1", auth: "auth1" }, + }), + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.deviceId).toBeTruthy() + expect(typeof json.deviceId).toBe("string") + // UUIDv4 pattern + expect(json.deviceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + }) + + it("re-subscribes the same endpoint and returns the same deviceId", async () => { + const endpoint1 = "https://push.example.com/api/v1/sub1" + const sub1 = { endpoint: endpoint1, keys: { p256dh: "key1", auth: "auth1" } } + const res1 = await server.post("/api/notifications/subscribe", sub1) + const id1 = res1.deviceId + + // Re-subscribe with the same endpoint; should return the same deviceId + const res2 = await server.post("/api/notifications/subscribe", sub1) + const id2 = res2.deviceId + + expect(id2).toBe(id1) + }) + + it("mints a different deviceId for a second endpoint (no duplicate per endpoint)", async () => { + const endpoint1 = "https://push.example.com/api/v1/sub1" + const endpoint2 = "https://push.example.com/api/v1/sub2" + const sub1 = { endpoint: endpoint1, keys: { p256dh: "key1", auth: "auth1" } } + const sub2 = { endpoint: endpoint2, keys: { p256dh: "key2", auth: "auth2" } } + + const res1 = await server.post("/api/notifications/subscribe", sub1) + const id1 = res1.deviceId + + const res2 = await server.post("/api/notifications/subscribe", sub2) + const id2 = res2.deviceId + + expect(id2).not.toBe(id1) + expect(id2).toBeTruthy() + }) + + it("reconciles by endpoint, ignoring the client's cached deviceId", async () => { + const endpoint = "https://push.example.com/api/v1/sub1" + const sub1 = { endpoint, keys: { p256dh: "key1", auth: "auth1" } } + + // First subscription gets id1 + const res1 = await server.post("/api/notifications/subscribe", sub1) + const id1 = res1.deviceId + + // Client sends a different cached id; server ignores it and returns id1 (endpoint match wins) + const sub2WithCachedId = { + ...sub1, + deviceId: "different-cached-id", + } + const res2 = await server.post("/api/notifications/subscribe", sub2WithCachedId) + const id2 = res2.deviceId + + expect(id2).toBe(id1) + }) +}) diff --git a/web/server.mjs b/web/server.mjs index 3ab79b3..afa5b98 100644 --- a/web/server.mjs +++ b/web/server.mjs @@ -22,6 +22,8 @@ import { createLineSplitter } from "../core/line-splitter.js" import { muteKey, unreadExcluding } from "../core/notif-mutes.js" import { buildHealth, shouldAlert } from "../core/notification-health.js" import sidechain from "../core/notifications-sidechain.js" +import { pushSendOptions } from "../core/push-send-options.js" +import { reconcileDeviceId } from "../core/push-subscriptions.js" import connector from "../core/remote-page-connector.js" import { createSettingsStore } from "../core/settings-store.js" import { createSlackApi } from "../core/slack-api.js" @@ -198,7 +200,7 @@ async function sendPushToAll(payload) { const data = JSON.stringify(trimPushPayload({ ...payload, unread })) for (let attempt = 0; attempt < 2; attempt++) { try { - await webpush.sendNotification(sub, data) + await webpush.sendNotification(sub, data, pushSendOptions()) sent++ console.log(`[push] dev=${dev} sent unread=${unread}`) return // success @@ -1041,13 +1043,15 @@ const server = http.createServer(async (req, res) => { if (p === "/api/notifications/subscribe" && POST) { const sub = await body(req) if (!sub?.endpoint) return json(res, { error: "missing endpoint" }, 400) - // Dedupe by endpoint URL so re-subscribing on the same device replaces in place. - // The record keeps `deviceId` (t093) so sendPushToAll can read that device's - // per-device master + mutes from ui-state and gate/stamp the push per device. + // E0: Reconcile by endpoint. A matching endpoint reuses its stored deviceId (so + // a storage wipe + re-subscribe on the same device recovers prior per-device prefs); + // a new endpoint gets a fresh UUID. The renderer adopts the returned id as the + // single source for device-keyed ui-state. + const { deviceId } = reconcileDeviceId(pushSubs, sub) pushSubs = pushSubs.filter((s) => s.endpoint !== sub.endpoint) - pushSubs.push(sub) + pushSubs.push({ ...sub, deviceId }) savePushSubs() - return json(res, { ok: true }) + return json(res, { deviceId }) } if (p === "/api/notifications/unsubscribe" && POST) { const { endpoint } = await body(req) From 49df653313824bce049e6a58b7761035b8674820 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Sat, 20 Jun 2026 01:26:31 +0700 Subject: [PATCH 2/2] refactor(push): tighten notification types and backfill t095 docs (t095) --- CLAUDE.md | 4 ++-- core/push-subscriptions.js | 5 +---- docs/conventions/docs-discipline.md | 4 ++-- src/lib/CLAUDE.md | 6 ++++++ src/lib/push-notification.test.ts | 6 +++--- src/lib/push-notification.ts | 8 +++++--- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08a7b48..d436900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **Pins (live-tab holders)**: A pin holds a remote tab (`targetId`), hidden from the Tabs list while linked. Click activates the linked tab or opens+links a fresh one; cmd/middle-click opens an unlinked throwaway tab. Created from a live tab only (toolbar star, right-click tab → Pin, or drag a tab into the Pinned section). A linked pin mirrors its tab's live title/favicon (restoring the saved title when the tab closes); the active pin shows an Arc-style URL-drift cue (a `/` separator and a favicon "Back to Pinned URL" button) when its tab navigates off the saved URL. Closing a pin's tab reverts it to unlinked; un-pinning (confirm dialog) returns the tab to the Tabs list. Cmd+1..9 indexes all pins then visible tabs; Ctrl+Tab cycles open pins + tabs. Link resolution is pure (`src/lib/pins.ts`); persistence/effects live in main + `app.tsx`. See `docs/adr/0004-pin-live-tab-model.md`. - **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation. - **Local tabs**: Real local web pages rendered as in-DOM Electron ``s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`. -- **Web build (no Electron)**: The same renderer runs as a plain web app via `web/server.mjs` — a Node HTTP proxy that serves the built `dist/` and exposes the whole `window.cdp` surface over **SSE** (`GET /api/events`, server→browser pushes incl. screencast frames) + **POST** (`/api/invoke`, `/api/send`, `/api/cdp-batch`, and REST for tabs/config/ui-state/pins/notifications). An optional **WebSocket** transport (`/api/ws`) supersedes SSE+POST when reachable — the user picks `Auto / Fastest (WS) / Streaming / Basic` in settings (2×2 toggle, web-only, `localStorage`). When WS is ready, frames + events + input all ride the one full-duplex socket. WS needs three lines in the nginx custom config (`proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade`, `proxy_set_header Connection $http_connection`); without them the client silently falls back to SSE+POST. See `docs/adr/0007-web-websocket-transport.md`. The proxy→CDP hop is still WS. The renderer installs a web `window.cdp` (`src/lib/cdp-web-transport.ts`, a thin assembler) when no preload exists, satisfying the same `CdpBridge` contract; the transport is split into named seams — a **Downlink** (`src/lib/downlink-dispatcher.ts`: one live WS-or-SSE source, decoder→filter→fan-out→toast-once dispatcher) and an **Uplink** (`src/lib/uplink-router.ts`: WS/stream/POST adapters + ready-transport router), with E2E sealed/opened once per direction through `src/lib/crypto-context.ts`. Input is coalesced via `src/lib/input-coalesce.ts`; the proxy acks frames itself, **except** for a WS client that announces ack-after-paint support (a plaintext `frame-ack-mode` control) — for that client the proxy **defers** its remote-ack and gates the next Screencast Frame on the client's post-paint `frame-ack`, so at most one frame is in flight on the link and a slow link can't accrue a stale-frame backlog (`core/frame-ack-gate.js`, the pure one-in-flight gate + a watchdog that frees the slot if a paint-ack never lands; the renderer fires the ack from `viewport.tsx` after it paints, via `window.cdp.ackPaintedFrame`; SSE/non-supporting clients keep the eager self-ack — see `docs/tasks/done/056-*`); theme follows `matchMedia`. **Always-on latency metrics** (`src/lib/latency-metrics.ts`, t057) ride the same seams: the WS uplink fires a plaintext `ping` (monotonic stamp) every 20s — a keepalive against proxy idle-reap plus an RTT/jitter EWMA probe — and the server echoes `{ t: "pong", seq, ts }` (RTT is measured only on the client clock); every Screencast Frame envelope carries a server `serverTs` so the client computes frame age (`now − serverTs + rtt/2`), recorded by the dispatcher before fan-out. Collection runs continuously (no `?perf=1`); the HUD is `src/components/latency-hud.tsx` (t059), always-on in the status bar. RTT/jitter report unavailable on the SSE+POST fallback. A `window.webCaps` flag (read through one accessor — `getCaps()` in `src/lib/caps.ts`, never inline) gates Electron-only surfaces. Local tabs are gated **structurally at the data source**: `useLocalTabs()` (`src/hooks/use-local-tabs.ts`) reads `caps.localTabs` once and returns an empty list + no-op handlers on web, so the renderer can't drive local-tab logic there (`LocalWebviews` never mounts, the new-tab kind toggle is hidden, Cmd+T/Cmd+Shift+T resolve to CDP only). Extensions are still gated at render only. `window.local` is a no-op stub (the safety net, not the mechanism). See `docs/conventions/feature-gates.md`. Pure shared logic lives in `core/` CJS modules — `cdp-endpoints.js` (`/json` URL builders), `settings-store.js` (settings/pins/ui-state), `notifications-sidechain.js` (Notification Side-Channel state machine + store, DI), `remote-page-connector.js` (Remote Page connect choreography, DI), `notifications.js` (dedup/cap/toast gating, Slack workspace key: `parseSlackContext`/`slackGroupKey`), `theme-emulation.js`, `crypto-envelope.js` (AES-256-GCM server side), `line-splitter.js` (NDJSON reassembly), `frame-throttle.js`, `frame-ack-gate.js`, `quality-tier.js`, and `notif-mutes.js` (per-device mute key + per-device unread, web push gate) — consumed by both `main.js` and `web/server.mjs`. Run `pnpm web`. See `docs/adr/0006-web-proxy-sse-transport.md`. The web build is an installable **PWA** (`public/manifest.webmanifest` with `APP_TITLE`-injected name + `public/sw.js`); the manifest is phone-and-iPad friendly (`"orientation": "any"` since t081 — iOS ignores the field, Android honors it; `viewport-fit=cover`; `interactive-widget=resizes-content` in the viewport meta so iOS shrinks the layout viewport when the keyboard opens; full height is driven by `--app-h` (set by `initAppHeight` in `src/lib/app-height.ts` to `visualViewport.height` — `100dvh` is only the pre-JS fallback); on iOS the keyboard also shifts the visual viewport up (`visualViewport.offsetTop`), so `app-height.ts` publishes `--vv-top` and toggles `html.kb-open` so `#root` translates to follow the visual viewport and bottom-anchored composers collapse the home-indicator inset the keyboard covers; `font-size: max(16px,1em)` on inputs prevents iOS auto-zoom on focus; safe-area insets are applied per-component — sidebar scroll content uses `pb-[max(0.5rem,env(safe-area-inset-bottom))]`, status bar uses `pb-[env(safe-area-inset-bottom)]`; sidebar defaults to 180px on viewports ≤1100px; an install nudge banner (`install-banner.tsx`) prompts Safari-tab visits to Add to Home Screen). Has a web-only **push-notification** toggle (`webPush` ui-state) that drives real **Web Push** on installed PWAs (iOS 16.4+) — VAPID-signed payloads from the server (`web-push` library) reach a service-worker `push` handler that fires `showNotification` even when the PWA is backgrounded or the screen is locked; clicks post-message back to the page and route through the same `notificationActivate` listeners as in-app clicks — on the Phone Shell that listener deep-routes into the **Conversation Reader** (t080): warm taps carry the payload entry (store entry wins via `resolvePushEntry`), cold taps (no window) ride a one-shot `?notif=` URL the SW sets on `openWindow`, consumed by `src/lib/push-route.ts` helpers once the store loads (gone entry → Inbox). The push payload also stamps the conversation identity (`channelId`/`slackKind`/`slackTs`/`slackThreadTs`) and an `unread` count, which the SW mirrors to the home-screen icon via `setAppBadge` (the page keeps it live as entries are read). **Per-device delivery (t093):** capture is global but *delivery* is per-device — each push subscription carries a `deviceId`, and `sendPushToAll` reads that device's master + mutes from ui-state (`notificationsEnabled_` + `notifMutes_`, written via the same device-keyed remap seam as `webPush_`; `core/settings-store.js` round-trips device-suffixed keys by prefix so they survive a PWA refresh). For each sub it **skips** the push when that device's master is off or it muted the entry's `muteKey` (`core/notif-mutes.js`: slack→`groupKey`, else `adapter`), and otherwise stamps a **per-device `unread`** (`unreadExcluding`, excluding that device's muted sources) so the badge stays honest per device. Defaults are opt-out (no stored master = on, no stored mutes = nothing muted; a sub with no `deviceId` keeps receiving). The global `notificationsEnabled` stays Electron-only (gates `shouldNotifyOs`). Foreground tabs still get the in-page `Notification` API as before. Subscriptions persist in `web-push-subs.json` next to the settings file. The toggle is disabled in Safari-tab mode (Web Push needs standalone display), and lowers input latency with a **streaming input channel** — one long-lived `POST /api/input-stream` (fetch `ReadableStream` body over HTTP/2, NDJSON frames reassembled by `core/line-splitter.js`) that a probe/`stream-ack` confirms before use and that falls back to `/api/cdp-batch` if a proxy buffers it. Streaming needs `proxy_request_buffering off` upstream to activate; when it can't (the default behind nginx/Authentik), mouse input is **event-driven** so it doesn't flood the fallback: a **hover gate** (`createHoverGate`) holds buttons-up moves and emits one resting position only when the cursor stops (drag moves bypass it and track live; clicks carry their own coords), and the `/api/cdp-batch` fallback is **single-flight with move-collapsing** (`createSingleFlight` — one POST in flight, consecutive `mouseMoved` collapse to the latest) so the rate auto-adapts to link RTT instead of backing up fire-and-forget POSTs and starving clicks. See `docs/tasks/done/013-*`. An optional **E2E mode** (set `E2E_PASSPHRASE` on the server) seals every `/api` body + SSE frame in AES-256-GCM (`core/crypto-envelope.js` server / `src/lib/crypto-envelope.ts` browser; the single owner is `src/lib/crypto-context.ts` — the uplink seals once before leaving, the downlink opens once on arrival) so content stays opaque to a TLS-intercepting proxy (Zscaler); a verifier handshake rejects a wrong passphrase, and with E2E off everything is plaintext as before. It defeats network content inspection, not endpoint screen capture. See `docs/tasks/done/012-*`. +- **Web build (no Electron)**: The same renderer runs as a plain web app via `web/server.mjs` — a Node HTTP proxy that serves the built `dist/` and exposes the whole `window.cdp` surface over **SSE** (`GET /api/events`, server→browser pushes incl. screencast frames) + **POST** (`/api/invoke`, `/api/send`, `/api/cdp-batch`, and REST for tabs/config/ui-state/pins/notifications). An optional **WebSocket** transport (`/api/ws`) supersedes SSE+POST when reachable — the user picks `Auto / Fastest (WS) / Streaming / Basic` in settings (2×2 toggle, web-only, `localStorage`). When WS is ready, frames + events + input all ride the one full-duplex socket. WS needs three lines in the nginx custom config (`proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade`, `proxy_set_header Connection $http_connection`); without them the client silently falls back to SSE+POST. See `docs/adr/0007-web-websocket-transport.md`. The proxy→CDP hop is still WS. The renderer installs a web `window.cdp` (`src/lib/cdp-web-transport.ts`, a thin assembler) when no preload exists, satisfying the same `CdpBridge` contract; the transport is split into named seams — a **Downlink** (`src/lib/downlink-dispatcher.ts`: one live WS-or-SSE source, decoder→filter→fan-out→toast-once dispatcher) and an **Uplink** (`src/lib/uplink-router.ts`: WS/stream/POST adapters + ready-transport router), with E2E sealed/opened once per direction through `src/lib/crypto-context.ts`. Input is coalesced via `src/lib/input-coalesce.ts`; the proxy acks frames itself, **except** for a WS client that announces ack-after-paint support (a plaintext `frame-ack-mode` control) — for that client the proxy **defers** its remote-ack and gates the next Screencast Frame on the client's post-paint `frame-ack`, so at most one frame is in flight on the link and a slow link can't accrue a stale-frame backlog (`core/frame-ack-gate.js`, the pure one-in-flight gate + a watchdog that frees the slot if a paint-ack never lands; the renderer fires the ack from `viewport.tsx` after it paints, via `window.cdp.ackPaintedFrame`; SSE/non-supporting clients keep the eager self-ack — see `docs/tasks/done/056-*`); theme follows `matchMedia`. **Always-on latency metrics** (`src/lib/latency-metrics.ts`, t057) ride the same seams: the WS uplink fires a plaintext `ping` (monotonic stamp) every 20s — a keepalive against proxy idle-reap plus an RTT/jitter EWMA probe — and the server echoes `{ t: "pong", seq, ts }` (RTT is measured only on the client clock); every Screencast Frame envelope carries a server `serverTs` so the client computes frame age (`now − serverTs + rtt/2`), recorded by the dispatcher before fan-out. Collection runs continuously (no `?perf=1`); the HUD is `src/components/latency-hud.tsx` (t059), always-on in the status bar. RTT/jitter report unavailable on the SSE+POST fallback. A `window.webCaps` flag (read through one accessor — `getCaps()` in `src/lib/caps.ts`, never inline) gates Electron-only surfaces. Local tabs are gated **structurally at the data source**: `useLocalTabs()` (`src/hooks/use-local-tabs.ts`) reads `caps.localTabs` once and returns an empty list + no-op handlers on web, so the renderer can't drive local-tab logic there (`LocalWebviews` never mounts, the new-tab kind toggle is hidden, Cmd+T/Cmd+Shift+T resolve to CDP only). Extensions are still gated at render only. `window.local` is a no-op stub (the safety net, not the mechanism). See `docs/conventions/feature-gates.md`. Pure shared logic lives in `core/` CJS modules — `cdp-endpoints.js` (`/json` URL builders), `settings-store.js` (settings/pins/ui-state), `notifications-sidechain.js` (Notification Side-Channel state machine + store, DI), `remote-page-connector.js` (Remote Page connect choreography, DI), `notifications.js` (dedup/cap/toast gating, Slack workspace key: `parseSlackContext`/`slackGroupKey`), `theme-emulation.js`, `crypto-envelope.js` (AES-256-GCM server side), `line-splitter.js` (NDJSON reassembly), `frame-throttle.js`, `frame-ack-gate.js`, `quality-tier.js`, and `notif-mutes.js` (per-device mute key + per-device unread, web push gate) — consumed by both `main.js` and `web/server.mjs`. Run `pnpm web`. See `docs/adr/0006-web-proxy-sse-transport.md`. The web build is an installable **PWA** (`public/manifest.webmanifest` with `APP_TITLE`-injected name + `public/sw.js`); the manifest is phone-and-iPad friendly (`"orientation": "any"` since t081 — iOS ignores the field, Android honors it; `viewport-fit=cover`; `interactive-widget=resizes-content` in the viewport meta so iOS shrinks the layout viewport when the keyboard opens; full height is driven by `--app-h` (set by `initAppHeight` in `src/lib/app-height.ts` to `visualViewport.height` — `100dvh` is only the pre-JS fallback); on iOS the keyboard also shifts the visual viewport up (`visualViewport.offsetTop`), so `app-height.ts` publishes `--vv-top` and toggles `html.kb-open` so `#root` translates to follow the visual viewport and bottom-anchored composers collapse the home-indicator inset the keyboard covers; `font-size: max(16px,1em)` on inputs prevents iOS auto-zoom on focus; safe-area insets are applied per-component — sidebar scroll content uses `pb-[max(0.5rem,env(safe-area-inset-bottom))]`, status bar uses `pb-[env(safe-area-inset-bottom)]`; sidebar defaults to 180px on viewports ≤1100px; an install nudge banner (`install-banner.tsx`) prompts Safari-tab visits to Add to Home Screen). Has a web-only **push-notification** toggle (`webPush` ui-state) that drives real **Web Push** on installed PWAs (iOS 16.4+) — VAPID-signed payloads from the server (`web-push` library) reach a service-worker `push` handler that fires `showNotification` even when the PWA is backgrounded or the screen is locked; clicks post-message back to the page and route through the same `notificationActivate` listeners as in-app clicks — on the Phone Shell that listener deep-routes into the **Conversation Reader** (t080): warm taps carry the payload entry (store entry wins via `resolvePushEntry`), cold taps (no window) ride a one-shot `?notif=` URL the SW sets on `openWindow`, consumed by `src/lib/push-route.ts` helpers once the store loads (gone entry → Inbox). The push payload also stamps the conversation identity (`channelId`/`slackKind`/`slackTs`/`slackThreadTs`) and an `unread` count, which the SW mirrors to the home-screen icon via `setAppBadge` (the page keeps it live as entries are read). **Per-device delivery (t093):** capture is global but *delivery* is per-device — each push subscription carries a `deviceId`, and `sendPushToAll` reads that device's master + mutes from ui-state (`notificationsEnabled_` + `notifMutes_`, written via the same device-keyed remap seam as `webPush_`; `core/settings-store.js` round-trips device-suffixed keys by prefix so they survive a PWA refresh). For each sub it **skips** the push when that device's master is off or it muted the entry's `muteKey` (`core/notif-mutes.js`: slack→`groupKey`, else `adapter`), and otherwise stamps a **per-device `unread`** (`unreadExcluding`, excluding that device's muted sources) so the badge stays honest per device. Defaults are opt-out (no stored master = on, no stored mutes = nothing muted; a sub with no `deviceId` keeps receiving). The global `notificationsEnabled` stays Electron-only (gates `shouldNotifyOs`). Foreground tabs still get the in-page `Notification` API as before. Subscriptions persist in `web-push-subs.json` next to the settings file. **Push delivery hardening (t095, ADR-0014):** the server is the authoritative source of `deviceId`, reconciled by push endpoint (`core/push-subscriptions.js`:`reconcileDeviceId`) — after a storage wipe the same endpoint recovers its prior `deviceId` and per-device prefs; the SW push handler (`src/lib/push-notification.ts`:`buildNotificationContent`) always calls `showNotification` (real payload or generic fallback) to avoid WebKit **userVisibleOnly** revocation; the server fans out with `urgency:"high"`, `TTL:1800` (`core/push-send-options.js`) for timely, non-stale delivery; on app foreground the client re-validates the subscription once (`src/lib/push-revalidate.ts`:`createPushRevalidateGate`) to recover a revoked sub before the next push arrives. See `CONTEXT.md` for **Web Push Subscription** and **userVisibleOnly revocation** glossary entries. The toggle is disabled in Safari-tab mode (Web Push needs standalone display), and lowers input latency with a **streaming input channel** — one long-lived `POST /api/input-stream` (fetch `ReadableStream` body over HTTP/2, NDJSON frames reassembled by `core/line-splitter.js`) that a probe/`stream-ack` confirms before use and that falls back to `/api/cdp-batch` if a proxy buffers it. Streaming needs `proxy_request_buffering off` upstream to activate; when it can't (the default behind nginx/Authentik), mouse input is **event-driven** so it doesn't flood the fallback: a **hover gate** (`createHoverGate`) holds buttons-up moves and emits one resting position only when the cursor stops (drag moves bypass it and track live; clicks carry their own coords), and the `/api/cdp-batch` fallback is **single-flight with move-collapsing** (`createSingleFlight` — one POST in flight, consecutive `mouseMoved` collapse to the latest) so the rate auto-adapts to link RTT instead of backing up fire-and-forget POSTs and starving clicks. See `docs/tasks/done/013-*`. An optional **E2E mode** (set `E2E_PASSPHRASE` on the server) seals every `/api` body + SSE frame in AES-256-GCM (`core/crypto-envelope.js` server / `src/lib/crypto-envelope.ts` browser; the single owner is `src/lib/crypto-context.ts` — the uplink seals once before leaving, the downlink opens once on arrival) so content stays opaque to a TLS-intercepting proxy (Zscaler); a verifier handshake rejects a wrong passphrase, and with E2E off everything is plaintext as before. It defeats network content inspection, not endpoint screen capture. See `docs/tasks/done/012-*`. - **Clipboard paste (t065)**: Two gesture-driven one-way bridges — no ambient background sync (focus/permission wall + privacy). **Local→remote text**: ⌘/Ctrl+V reads the local clipboard (`window.cdp.readClipboard()` via Electron IPC / `navigator.clipboard` on web) and calls `RemotePage.paste(text)` → `Input.insertText` (plain) or pre-seed + forwarded ⌘V (rich). **Local→remote image**: `window.cdp.readClipboardImage()` (Electron IPC, reads `clipboard.readImage()`) or the native browser `paste` event (web — Safari/iPad blocks `navigator.clipboard.readText`/images; instead ⌘V is not `preventDefault`ed so the browser fires a `paste` ClipboardEvent on the document); either path calls `RemotePage.pasteImage(dataUrl)` → `Runtime.evaluate` synthesizes a paste `ClipboardEvent` with a `DataTransfer` carrying the image as a `File`. **Typing surface guard**: bare `?` (and other bare-char shortcuts) forward to the remote page when `activeKind` is `cdp` or `local` (`isTypingSurface` in `src/lib/typing-surface.ts`); the shortcut overlay opens via `⌘/` instead. `core/clipboard.js` owns the pure `Browser.grantPermissions` enum-fallback helpers and `selectPasteRoute`. - **Notifications side-channel**: A per-target read-only CDP socket (no screencast, no input) stays attached to background tabs that match a Notification Adapter (Teams, Outlook, Slack). Lifecycle and state machine live in `core/notifications-sidechain.js` (`createNotificationCenter`, DI) — consumed by both `main.js` and `web/server.mjs`; the server runs it headless. A capture script (per adapter, in `inject/`) is injected at document-start and ships toasts through a `__cdpNotify` binding. Pure dedup/cap/read-model helpers remain in `core/notifications.js`. Each adapter carries a `name`, hostname `match` regex, capture `script`, `iconUrl`, optional `activate` tagged union (`spa-link` | `thread`) for deep-opens, and an optional `groupKey(url)` hook (URL-derived per-workspace bucketing) — adding an adapter is one config entry in `ADAPTERS`. Capture style varies by site: Teams/Outlook use a `MutationObserver` on the site's own in-app toast DOM; Slack uses a two-modality design (ADR-0011): the **Slack Content Sweep** (web build only) is the authoritative capture path — the server polls Slack's web API using extracted `xoxc`/`d`-cookie creds from a live Slack tab, synthesizes entries keyed `slack:{groupId}:{channel}:{ts}` (`groupId = enterprise_id || teamId`, t092 — an Enterprise Grid registers the org as a pseudo-team alongside its member workspaces and both surface the same shared channels, so keying by the Grid group collapses the org+workspace duplicate via the existing id-dedup; the concrete `teamId` is kept on the entry for the deep-link), and is the sole Slack store writer. The in-page hijack script (`inject/slack-notify.js`) is demoted to a **"sweep now" trigger**: a fired notification immediately schedules a sweep of that workspace so delivery is sub-second without the hijack writing to the store (no cross-path dedup needed). A 15s periodic sweep is the completeness backstop; a per-workspace **parked tab** keeps one Slack tab alive on the remote browser so creds self-refresh. A workspace is persisted in `slack-workspaces.json` (non-secret metadata only — no creds on disk) when first seen as its own tab. Clicking a notification activates the tab, then the renderer's activation registry (`src/lib/notification-activation.ts`) maps the `activate` intent to a Remote Page intention (`navigateSpa` for Outlook + Slack channel deep-links, `openTeamsThread` for Teams chats). Teams has no conversation URL (the URL stays bare `/v2/`), so thread-id clicks drive `openTeamsThread`; Slack reuses `spa-link` to `/client/{team}/{channel}` (best-effort — degrades to tab-only when the notification carries no channel id). See `docs/adr/0003-notifications-side-channel.md` and `docs/adr/0011-slack-content-sweep-guaranteed-delivery.md`. @@ -142,7 +142,7 @@ cdp-browser/ │ ├── hotkey-registry.ts # Pure action registry shared by ⌘K palette + ⌘/ overlay: buildActions/filterActions/groupForOverlay (effects injected by app.tsx). See docs/tasks/done/058 │ ├── find-bar.ts # Pure in-page find reducer: open/close/setQuery/setTotal/next-prev(wrap) + counterLabel (t001) │ ├── push-notification.ts # Pure E1: revocation-proof SW push handler (t095). buildNotificationContent(data) → {title, options}; always renders (fallback on parse-fail) - │ ├── push-revalidate.ts # Pure E1b: once-per-foreground subscription re-validation gate (t095, ADR-0012). createPushRevalidateGate() → {shouldRevalidateNow(visible)} + │ ├── push-revalidate.ts # Pure E1b: once-per-foreground subscription re-validation gate (t095, ADR-0014). createPushRevalidateGate() → {shouldRevalidateNow(visible)} │ ├── cdp-web-transport.ts # Web build: thin assembler wiring Downlink + Uplink + REST bridge into window.cdp │ ├── downlink-dispatcher.ts # Web build: Downlink seam (one WS/SSE source) + Dispatcher (decode→fan-out→toast-once) │ ├── uplink-router.ts # Web build: Uplink seam (WS/stream/POST adapters) + ready-transport router diff --git a/core/push-subscriptions.js b/core/push-subscriptions.js index 0dab147..2a3788a 100644 --- a/core/push-subscriptions.js +++ b/core/push-subscriptions.js @@ -4,7 +4,6 @@ import { randomUUID } from "crypto" export function reconcileDeviceId(existingSubs, incoming) { - // Find if endpoint already exists const existing = existingSubs.find((sub) => sub.endpoint === incoming.endpoint) if (existing && existing.deviceId) { @@ -12,7 +11,5 @@ export function reconcileDeviceId(existingSubs, incoming) { return { deviceId: existing.deviceId, isNew: false } } - // New endpoint — generate a fresh UUID v4 - const deviceId = randomUUID() - return { deviceId, isNew: true } + return { deviceId: randomUUID(), isNew: true } } diff --git a/docs/conventions/docs-discipline.md b/docs/conventions/docs-discipline.md index 604dec9..2dc004f 100644 --- a/docs/conventions/docs-discipline.md +++ b/docs/conventions/docs-discipline.md @@ -87,7 +87,7 @@ Any non-trivial design decision gets an ADR. Examples of "non-trivial": ADRs are **append-only**. When a decision changes, write a new ADR that supersedes the old one. Update the old ADR's *Status* line to reference the superseder, but never edit its body. The history is the documentation. -Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`. +Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`. --- @@ -151,4 +151,4 @@ If a doc has rotted beyond repair, delete it and write a fresh one. A wrong doc _Docs you don't maintain are a liability. Docs you maintain are a force multiplier._ -_Last revisited: 2026-06-03_ +_Last revisited: 2026-06-20_ diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index 3df9262..0aed11a 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -42,6 +42,10 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`push-route.ts`** — Pure push deep-route helpers (t080, ADR-0012 §6). `notifIdFromSearch(search)` reads the one-shot `?notif=` the service worker sets on a cold-start `openWindow` (no client existed to postMessage); `stripNotifParam(search)` consumes it; `resolvePushEntry(id, store, payload?)` picks the store entry over the (possibly slimmer) push payload, null when neither knows the id — the Inbox is the fallback. `app.tsx` owns the effects: the warm-path `onNotificationActivate` listener routes phone → reader / wide → activation, the cold-start effect waits for the store load, and the badge mirror (`navigator.setAppBadge`) tracks the live unread count. Tested by `push-route.test.ts`. +**`push-notification.ts`** — Pure SW push payload builder (t095, E1). `buildNotificationContent(data)` maps a raw push payload object to `{ title, options }` for `showNotification`; a parse-fail or missing-data path always returns a valid fallback (`NOTIFICATION_FALLBACK_TAG`) so the SW never finishes a `push` event without showing a notification — preventing WebKit **userVisibleOnly** subscription revocation. Tested by `push-notification.test.ts`. See `CONTEXT.md` **userVisibleOnly revocation**. + +**`push-revalidate.ts`** — Pure once-per-foreground subscription re-validation gate (t095, E1b, ADR-0014). `createPushRevalidateGate()` returns `{ shouldRevalidateNow(visible) }` — a tiny state machine that arms on hidden→visible transition, fires `true` once per foreground stay, then locks out until the next hide. `app.tsx` calls this on `visibilitychange` and re-subscribes when it fires, recovering a revoked subscription before the next push arrives. Tested by `push-revalidate.test.ts`. + **`screencast-keys.ts`** — Pure OSK key helpers for the on-screen keyboard bridge (t084/t086). `VKEY` maps Web key names to Windows virtual-key codes (`keyCode`) — the field `Input.dispatchKeyEvent` uses; a 0 means the remote ignores the key. `KEYDOWN_KEYS` is the set of non-text keys forwarded from `keydown` (Enter, Tab, Escape, arrows, Home/End/Delete — Backspace is absent, routed by the input-delta path instead). `synthKey(key)` builds the `SynthKey` payload with zeroed modifier flags. Kept outside the component so the vkey mapping and routing decisions are unit-testable (`screencast-keys.test.ts`). **`text-input-delta.ts`** — Pure on-screen-keyboard diff (t084). `diffInput(prev, next)` returns `{ backspaces, insert }` — the minimal delete-from-tail + insert that turns `prev` into `next`, computed from their longest common prefix. Diffing (not per-keystroke capture) is what makes autocorrect, predictive text, paste, and composing input (Vietnamese Telex, CJK IME) work: the hidden field holds the composed result and we sync the delta. Pure. Tested by `text-input-delta.test.ts`. @@ -128,6 +132,8 @@ The transport is split into three named seams assembled by a thin shim: - Reader (`reader.ts`) is pure — entry in, route out. The fetch effect and load states live in `conversation-reader.tsx`; reading marks the thread read locally only (never `conversations.mark`). - Slack Reply (`slack-reply.ts`) is pure — `selectReplyTarget` is the only place the reply-target policy exists; `reduceSend` never loses a draft on failure. No fetch, no React. - Push Route (`push-route.ts`) is pure — URL/data in, entry out. The SW sets `?notif=`, `app.tsx` consumes it exactly once and executes the routing. +- Push Notification (`push-notification.ts`) is pure — `buildNotificationContent` always returns a valid `{ title, options }` with a fallback; the SW must call `showNotification` unconditionally or risk revocation. +- Push Revalidate (`push-revalidate.ts`) is pure — no DOM, no timers, no network. `shouldRevalidateNow` is a state machine over a visibility boolean; `app.tsx` drives the actual subscribe call. - Screencast Keys (`screencast-keys.ts`) is pure — no DOM, no React. `synthKey` always zeroes modifier flags; `keyCode` 0 is the caller's signal to skip forwarding. - Text Input Delta (`text-input-delta.ts`) is pure — no DOM, no state. `diffInput` is idempotent; if `prev === next` it returns zero backspaces + empty insert. - Touch Gesture (`touch-gesture.ts`) is pure — no DOM, no timers, no React. The long-press deadline is a caller-driven `poll(now)`; a fresh instance per gesture so no state leaks. `viewport.tsx` owns the `setTimeout` that calls `poll` and maps emitted events to `forwardInput`. diff --git a/src/lib/push-notification.test.ts b/src/lib/push-notification.test.ts index 8f0215b..f474f7e 100644 --- a/src/lib/push-notification.test.ts +++ b/src/lib/push-notification.test.ts @@ -23,7 +23,7 @@ describe("buildNotificationContent", () => { expect(result.options.tag).toBe("slack:team1:channel1:ts123") expect(result.options.icon).toBe("/icons/slack.png") expect(result.options.badge).toBe("/icons/icon-192.png") - expect((result.options as any).timestamp).toBe(1718000000000) + expect(result.options.timestamp).toBe(1718000000000) expect(result.options.data).toEqual(data) }) @@ -91,8 +91,8 @@ describe("buildNotificationContent", () => { } const result = buildNotificationContent(data as any) const after = Date.now() - expect((result.options as any).timestamp).toBeGreaterThanOrEqual(before) - expect((result.options as any).timestamp).toBeLessThanOrEqual(after) + expect(result.options.timestamp).toBeGreaterThanOrEqual(before) + expect(result.options.timestamp).toBeLessThanOrEqual(after) }) it("always includes badge for consistency across platforms", () => { diff --git a/src/lib/push-notification.ts b/src/lib/push-notification.ts index 6897f90..a461bc8 100644 --- a/src/lib/push-notification.ts +++ b/src/lib/push-notification.ts @@ -1,8 +1,10 @@ export const NOTIFICATION_FALLBACK_TAG = "cdp-fallback" +export type PushNotificationOptions = NotificationOptions & { data?: unknown; timestamp?: number } + export interface PushNotificationContent { title: string - options: NotificationOptions + options: PushNotificationOptions } export function buildNotificationContent(data: any): PushNotificationContent { @@ -14,12 +16,12 @@ export function buildNotificationContent(data: any): PushNotificationContent { badge: "/icons/icon-192.png", tag: NOTIFICATION_FALLBACK_TAG, data: {}, - } as any, + }, } } const title = data.title || "CDP Browser" - const options: NotificationOptions & { timestamp?: number } = { + const options: PushNotificationOptions = { body: data.body || "", icon: data.icon || "/icons/icon-192.png", badge: "/icons/icon-192.png",