diff --git a/CLAUDE.md b/CLAUDE.md index b827eca..974c04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,8 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **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`, and `quality-tier.js` — 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 **iPad-targeted** (`"orientation": "landscape"`, `viewport-fit=cover`; `body` uses `100dvh` for full height including Safari URL-bar; 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. 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-*`. - **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`. +- **Notification tab keep-alive (t066)**: Chromium freezes idle background tabs (~5 min), pausing the page JS that the capture script hooks (`window.Notification`) — so background tabs silently stop delivering notifications and only the active tab notified. The side-channel now sends `Page.setWebLifecycleState({state:"active"})` on attach and re-applies it every `reconcile` (the browser can re-freeze). This un-freezes the tab **without** making it "visible" (verified against the CDP spec: `setWebLifecycleState` only takes `"frozen"|"active"` and governs freeze state, not `document.visibilityState`), so Slack still treats the tab as hidden and keeps firing desktop notifications for the side-channel to capture. The keep-alive lives in `core/notifications-sidechain.js` (`sideChannels` map value is now `{ ws, keepAlive }`), so both Electron and the headless web server benefit. **Out of scope (→ t067):** notifications raised from a service-worker `push` handler (`registration.showNotification`) run in a separate realm the page hook can't reach. +- **Notification favicon (t066, Electron)**: The OS notification banner and the macOS dock icon carry the source app's favicon so you can tell *which* app pinged you. `dockOverlayIcon(list)` (pure, `core/notifications.js`) picks the newest-unread entry's icon (null when all read → restore plain icon). main.js fetches the favicon bytes (no browser CORS wall), passes them as a data URL into the chrome renderer via `executeJavaScript` to composite base-icon + favicon-bottom-right (the renderer's `` decodes `.ico`; data-URL inputs never taint the canvas), and turns the returned PNG data URLs into `nativeImage`s for `app.dock.setIcon` + the `Notification` `icon`. Synced on every new entry, mark-read/unread/all, clear, and launch. - **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 has no in-app toast**, so its script (`inject/slack-notify.js`, t064) hijacks the Web Notifications API at document-start — it patches `window.Notification` to intercept every fired notification, and forces `Notification.permission` → `"granted"` so Slack actually fires (a remote browser's permission is often `"default"`, which would otherwise suppress all notifications; service-worker `push`-handler notifications are out of scope — a separate JS realm unreachable from the page script). Multiple Slack workspaces (one tab per workspace — switched-away workspaces in a single tab aren't running JS) share the `app.slack.com` origin, so per-origin grouping would merge them; the Slack adapter's `groupKey(url)` derives `slack:{teamId}` from the tab URL (`slackGroupKey`/`parseSlackContext` in `core/notifications.js`; `T…` standard or `E…` Enterprise Grid, legacy subdomain fallback) to keep per-workspace unread counts distinct. 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`. ## File Structure diff --git a/core/notifications-sidechain.js b/core/notifications-sidechain.js index e6a3adc..0ef6229 100644 --- a/core/notifications-sidechain.js +++ b/core/notifications-sidechain.js @@ -102,7 +102,7 @@ function createNotificationCenter(deps) { // back-compatible display. activate: n.activate || null, targetEntity: n.targetEntity || null, - icon: (adapter || {}).iconUrl || null, + icon: adapter?.iconUrl || null, ts: n.ts || (deps.now ? deps.now() : Date.now()), }, cap, @@ -117,17 +117,32 @@ function createNotificationCenter(deps) { const adapter = adapterFor(target.url) if (!adapter || !target.webSocketDebuggerUrl) return const ws = new WebSocketCtor(target.webSocketDebuggerUrl) - sideChannels.set(target.id, ws) let cmdId = 1 + let opened = false const cdp = (method, params) => ws.send(JSON.stringify({ id: cmdId++, method, params: params || {} })) + // Keep the remote Tab's page alive so its capture script keeps firing even when the + // Tab is backgrounded on the remote browser. Chromium freezes idle background tabs + // (~5 min), which pauses the page JS that calls `new Notification()` — so background + // Tabs silently stop delivering toasts (the asymmetry where only the active Tab + // notified). Forcing the web lifecycle to "active" prevents the freeze WITHOUT making + // the page "visible" (visibility is orthogonal in the CDP spec — verified against + // Page.setWebLifecycleState, which only takes "frozen"|"active"), so Slack still treats + // the Tab as hidden and keeps firing desktop notifications for the side-channel to + // capture. Re-applied every reconcile because the browser can re-freeze. See t066. + const keepAlive = () => { + if (opened) cdp("Page.setWebLifecycleState", { state: "active" }) + } + sideChannels.set(target.id, { ws, keepAlive }) ws.on("open", () => { + opened = true cdp("Runtime.enable") cdp("Page.enable") cdp("Runtime.addBinding", { name: NOTIFY_BINDING }) // document-start for future loads + the already-loaded document. cdp("Page.addScriptToEvaluateOnNewDocument", { source: sourceFor(adapter) }) cdp("Runtime.evaluate", { expression: sourceFor(adapter) }) + keepAlive() }) ws.on("message", (data) => { try { @@ -138,7 +153,8 @@ function createNotificationCenter(deps) { } catch {} }) const drop = () => { - if (sideChannels.get(target.id) === ws) sideChannels.delete(target.id) + const cur = sideChannels.get(target.id) + if (cur && cur.ws === ws) sideChannels.delete(target.id) } ws.on("close", drop) ws.on("error", drop) @@ -158,7 +174,7 @@ function createNotificationCenter(deps) { if (!Array.isArray(list)) return const matched = list.filter((t) => t.type === "page" && adapterFor(t.url)) const liveIds = new Set(matched.map((t) => t.id)) - for (const [id, ws] of sideChannels) { + for (const [id, { ws }] of sideChannels) { if (!liveIds.has(id)) { try { ws.close() @@ -167,6 +183,9 @@ function createNotificationCenter(deps) { } } for (const t of matched) if (!sideChannels.has(t.id)) attach(t) + // Re-apply keep-alive to every live side-channel each cycle — the browser may have + // re-frozen a backgrounded Tab since the last pass (t066). + for (const [, ch] of sideChannels) ch.keepAlive() } return { @@ -195,7 +214,7 @@ function createNotificationCenter(deps) { }, unreadCount: () => unreadCount(notifications), close: () => { - for (const [, ws] of sideChannels) { + for (const [, { ws }] of sideChannels) { try { ws.close() } catch {} diff --git a/core/notifications-sidechain.test.ts b/core/notifications-sidechain.test.ts index a43dd0f..3e6e50e 100644 --- a/core/notifications-sidechain.test.ts +++ b/core/notifications-sidechain.test.ts @@ -172,6 +172,36 @@ describe("reconcile — idempotent / drop", () => { }) }) +describe("keep-alive (t066) — prevent background-tab freeze", () => { + it("forces the page web lifecycle to active on open so a backgrounded Tab keeps firing notifications", async () => { + const { center } = makeCenter() + await center.reconcile([teamsTarget()]) + const ws = FakeWs.instances[0] + ws.open() + const keepAlive = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState") + expect(keepAlive).toHaveLength(1) + expect(keepAlive[0].params.state).toBe("active") + }) + + it("does not send keep-alive before the socket opens", async () => { + const { center } = makeCenter() + await center.reconcile([teamsTarget()]) + const ws = FakeWs.instances[0] + const count = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState").length + expect(count).toBe(0) + }) + + it("re-applies keep-alive on each reconcile (browser may re-freeze the Tab)", async () => { + const { center } = makeCenter() + await center.reconcile([teamsTarget()]) + const ws = FakeWs.instances[0] + ws.open() + await center.reconcile([teamsTarget()]) // same target, socket already open + const count = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState").length + expect(count).toBe(2) + }) +}) + describe("ingest dedup", () => { it("drops a duplicate toast within the dedup window — one stored entry, one onEntry", async () => { const { center, onEntry } = makeCenter() diff --git a/core/notifications.js b/core/notifications.js index 60766bf..64defb9 100644 --- a/core/notifications.js +++ b/core/notifications.js @@ -99,6 +99,15 @@ function unreadCount(list) { return list.reduce((acc, n) => acc + (n.read ? 0 : 1), 0) } +// The favicon to overlay on the app's dock icon: the icon of the most-recent UNREAD +// notification (the list is newest-first), or null when nothing is unread (clear the +// overlay, restore the plain app icon). Pure — main.js owns the image composite + +// app.dock.setIcon effect. See t066. +function dockOverlayIcon(list) { + const newestUnread = list.find((n) => !n.read) + return newestUnread?.icon || null +} + // { [targetId]: unreadCount } — only targets with at least one unread appear. function unreadByTarget(list) { const out = {} @@ -115,6 +124,7 @@ module.exports = { slackGroupKey, ingest, shouldNotifyOs, + dockOverlayIcon, markRead, markUnread, markAllRead, diff --git a/core/notifications.test.ts b/core/notifications.test.ts index b9d0b37..d526448 100644 --- a/core/notifications.test.ts +++ b/core/notifications.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" // CommonJS module shared with main.js (which can't import src/lib ESM). import { + dockOverlayIcon, groupKeyFor, ingest, markAllRead, @@ -182,3 +183,36 @@ describe("shouldNotifyOs", () => { ).toBe(false) }) }) + +describe("dockOverlayIcon (t066)", () => { + it("returns null for an empty list", () => { + expect(dockOverlayIcon([])).toBeNull() + }) + it("returns null when every notification is read", () => { + expect( + dockOverlayIcon([ + { read: true, icon: "slack.png" }, + { read: true, icon: "teams.ico" }, + ]), + ).toBeNull() + }) + it("returns the icon of the most-recent unread (list is newest-first)", () => { + expect( + dockOverlayIcon([ + { read: false, icon: "slack.png" }, + { read: false, icon: "teams.ico" }, + ]), + ).toBe("slack.png") + }) + it("skips read entries ahead of the newest unread", () => { + expect( + dockOverlayIcon([ + { read: true, icon: "slack.png" }, + { read: false, icon: "teams.ico" }, + ]), + ).toBe("teams.ico") + }) + it("returns null when the newest unread carries no icon", () => { + expect(dockOverlayIcon([{ read: false }])).toBeNull() + }) +}) diff --git a/docs/tasks/066-keep-notif-tabs-alive-dock-favicon.md b/docs/tasks/066-keep-notif-tabs-alive-dock-favicon.md new file mode 100644 index 0000000..1c9bda9 --- /dev/null +++ b/docs/tasks/066-keep-notif-tabs-alive-dock-favicon.md @@ -0,0 +1,57 @@ +# 066 — keep notification tabs alive + favicon on dock/banner + +- **Status:** in-progress +- **Mode:** HITL +- **Estimate:** 1d +- **Depends on:** none +- **Blocks:** 067 (service-worker push capture) + +## Goal + +Background Tabs on the remote browser silently stop delivering notifications: Chromium +freezes idle background tabs (~5 min), which pauses the page JS that the capture script +hooks (`window.Notification`), so only the *active* Tab keeps notifying. After this task, +every Notification-Adapter Tab (Teams / Outlook / Slack) is held in the "active" web +lifecycle state via the side-channel, so background Tabs keep firing notifications. And the +OS notification + the macOS dock icon now carry the source app's favicon, so you can tell +*which* app pinged you at a glance. + +## Why now + +This is the root cause of "only the focused tab notifies my real machine". It also unblocks +067 (service-worker push capture), which only matters once the page stays alive long enough +to be worth supplementing. + +## Acceptance criteria + +- [ ] Every adapter-matching Tab's side-channel sends `Page.setWebLifecycleState({state:"active"})` on open. +- [ ] Keep-alive is re-applied on every `reconcile` cycle (browser can re-freeze). +- [ ] Keep-alive does NOT make the page "visible" (Slack must keep firing desktop notifications). +- [ ] OS notification banner shows the source adapter's favicon. +- [ ] macOS dock icon shows the newest-unread app's favicon composited bottom-right; cleared when unread → 0. +- [ ] Dock overlay restores from persisted unread on launch and updates on mark-read/clear. + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [x] `core/notifications-sidechain.js` keep-alive — sends `setWebLifecycleState active` on open, re-applies per reconcile, not before open. +- [x] `core/notifications.js` `dockOverlayIcon(list)` — newest-unread icon, null when all read / empty / no icon. + +### Layer 2 — Manual smoke (CDP/IPC) + +- [ ] Open ≥2 Slack workspace Tabs; background one for >5 min; send it a message → OS notification fires on the Mac. +- [ ] Notification banner shows the Slack favicon. +- [ ] Dock icon shows the Slack favicon badge; mark-all-read → badge clears. +- [ ] Teams (`.ico` favicon) renders in both banner and dock (renderer decodes `.ico`). + +### Layer 3 — Visual review + +- [ ] Dock icon composite looks crisp at retina (white plate + favicon bottom-right). + +## Design notes + +- **Contracts changed:** `core/notifications-sidechain.js` `sideChannels` map value `ws → { ws, keepAlive }`; new pure `dockOverlayIcon(list)` in `core/notifications.js`. +- **CDP fact (verified vs protocol docs):** `Page.setWebLifecycleState` accepts only `"frozen"|"active"` and governs freeze state, not `document.visibilityState` — so "active" un-freezes without un-hiding. +- **Compositing:** done in the chrome renderer via `executeJavaScript` (its `` decodes `.ico`; favicon bytes are fetched in main and passed as a data URL, so the canvas is never cross-origin-tainted). main turns the returned PNG data URLs into `nativeImage`s for `app.dock.setIcon` + the notification `icon`. +- **New ADR needed?** no — tuning inside ADR-0003 (notifications side-channel). diff --git a/main.js b/main.js index 72ec844..ecb33b2 100644 --- a/main.js +++ b/main.js @@ -11,6 +11,7 @@ const { desktopCapturer, systemPreferences, dialog, + nativeImage, } = require("electron") const path = require("node:path") const fs = require("node:fs") @@ -478,7 +479,7 @@ ipcMain.handle("cdp:get-theme-source", () => settingsStore.getThemeSource()) // The whole side-channel lifecycle + store lives in the shared core; main.js // injects only Electron effects (capture-script reads, /json target list, the // persisted store file, the OS Notification + dock badge gated by shouldNotifyOs). -const { shouldNotifyOs } = require("./core/notifications") +const { shouldNotifyOs, dockOverlayIcon } = require("./core/notifications") const { createNotificationCenter } = require("./core/notifications-sidechain") // Persisted store (separate from settings.json to keep that file lean). @@ -501,6 +502,127 @@ function updateBadge() { if (typeof app.setBadgeCount === "function") app.setBadgeCount(notificationCenter.unreadCount()) } +// --- Dock icon composite (t066): overlay the notifying app's favicon on the bottom-right +// of CDP Browser's dock icon, so the dock tells you WHICH app pinged you (Slack vs Teams), +// not just a number. The compositing runs in the chrome renderer (its decodes .ico +// + data-URL inputs don't taint the canvas), driven from main via executeJavaScript. +const APP_ICON_PATH = path.join(__dirname, "build", "icon.png") +let baseIconDataUrl = null +function baseIcon() { + if (baseIconDataUrl != null) return baseIconDataUrl + try { + baseIconDataUrl = `data:image/png;base64,${fs.readFileSync(APP_ICON_PATH).toString("base64")}` + } catch { + baseIconDataUrl = "" + } + return baseIconDataUrl +} + +// Fetch a remote favicon's bytes in the main process (no browser CORS wall) and return a +// data URL, memoized per source URL. Returns "" on failure. A 3s timeout is mandatory: a +// hung favicon fetch (corporate proxy / Zscaler black-holing slack-edge.com etc.) must +// never stall a caller. This is decorative; it can fail freely. +const faviconDataUrlCache = new Map() +// Normalized 64px PNG favicon for the notification banner, keyed by source icon URL. Warmed +// by syncDockIcon so onEntry can attach the banner icon synchronously (never blocking). +const badgeDataUrlCache = new Map() +async function faviconDataUrl(url) { + if (!url) return "" + if (faviconDataUrlCache.has(url)) return faviconDataUrlCache.get(url) + let out = "" + try { + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const mime = res.headers.get("content-type") || "image/png" + const buf = Buffer.from(await res.arrayBuffer()) + out = `data:${mime};base64,${buf.toString("base64")}` + } + } catch {} + faviconDataUrlCache.set(url, out) + return out +} + +// In the renderer: draw base icon + favicon-in-corner, plus a normalized 64px favicon PNG +// for the notification banner. Returns { dock, badge } PNG data URLs, or null on failure. +async function composeDockBadge(faviconUrl) { + const wc = chromeWc() + const base = baseIcon() + if (!wc || !base || !faviconUrl) return null + const expr = `(async () => { + // Resolve to null on error OR timeout — never hang executeJavaScript on a stuck decode. + const load = (src) => new Promise((res) => { + const img = new Image() + const done = (v) => res(v) + img.onload = () => done(img); img.onerror = () => done(null) + setTimeout(() => done(null), 2500) + img.src = src + }) + try { + const [base, fav] = await Promise.all([load(${JSON.stringify(base)}), load(${JSON.stringify(faviconUrl)})]) + if (!base || !fav) return null + const S = base.naturalWidth || 1024 + const c = document.createElement("canvas"); c.width = S; c.height = S + const x = c.getContext("2d") + x.drawImage(base, 0, 0, S, S) + const bs = Math.round(S * 0.42), pad = Math.round(S * 0.04) + const bx = S - bs - pad, by = S - bs - pad, r = Math.round(bs * 0.22) + x.save() + x.beginPath() + x.moveTo(bx + r, by) + x.arcTo(bx + bs, by, bx + bs, by + bs, r) + x.arcTo(bx + bs, by + bs, bx, by + bs, r) + x.arcTo(bx, by + bs, bx, by, r) + x.arcTo(bx, by, bx + bs, by, r) + x.closePath() + x.shadowColor = "rgba(0,0,0,0.35)"; x.shadowBlur = Math.round(S * 0.02) + x.fillStyle = "#fff"; x.fill() + x.restore() + const inset = Math.round(bs * 0.12) + x.drawImage(fav, bx + inset, by + inset, bs - 2 * inset, bs - 2 * inset) + const fc = document.createElement("canvas"); fc.width = 64; fc.height = 64 + fc.getContext("2d").drawImage(fav, 0, 0, 64, 64) + return { dock: c.toDataURL("image/png"), badge: fc.toDataURL("image/png") } + } catch { return null } + })()` + try { + return await wc.executeJavaScript(expr) + } catch { + return null + } +} + +function setDockIcon(dataUrl) { + if (!app.dock || !dataUrl) return + try { + app.dock.setIcon(nativeImage.createFromDataURL(dataUrl)) + } catch {} +} +function clearDockIcon() { + if (!app.dock) return + try { + app.dock.setIcon(nativeImage.createFromPath(APP_ICON_PATH)) + } catch {} +} + +// Reconcile the dock icon with the store: show the newest-unread app's favicon, or restore +// the plain icon when nothing is unread. Fire-and-forget — callers MUST NOT await this on a +// path that gates a notification (a hung favicon fetch would swallow the toast). Also warms +// badgeDataUrlCache so the next notification can attach the banner icon synchronously. +async function syncDockIcon() { + try { + const iconUrl = dockOverlayIcon(notificationCenter.list()) + if (!iconUrl) { + clearDockIcon() + return + } + const favUrl = await faviconDataUrl(iconUrl) + const composed = favUrl ? await composeDockBadge(favUrl) : null + if (composed?.badge) badgeDataUrlCache.set(iconUrl, composed.badge) + if (composed?.dock) setDockIcon(composed.dock) + else clearDockIcon() + } catch {} +} + // Retain shown Notification objects: Electron/V8 garbage-collects a Notification with no // live reference, and the collected object never delivers its `click` event — the banner // shows but clicking it does nothing. Held until the user clicks or it closes. @@ -521,6 +643,10 @@ const notificationCenter = createNotificationCenter({ updateBadge() chromeSend("cdp:notification", entry) + // Fire the OS notification FIRST and synchronously — it must NEVER be gated by favicon + // or network work (a hung favicon fetch previously swallowed every toast). The banner + // icon is best-effort: use the cached normalized favicon if we already have it, else + // fire without one (the app icon shows regardless). The dock sync below warms the cache. const windowFocused = !!(mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) if ( shouldNotifyOs(entry, { @@ -530,7 +656,14 @@ const notificationCenter = createNotificationCenter({ }) && Notification.isSupported() ) { - const osN = new Notification({ title: entry.title || entry.source, body: entry.body }) + const opts = { title: entry.title || entry.source, body: entry.body } + const badge = entry.icon && badgeDataUrlCache.get(entry.icon) + if (badge) { + try { + opts.icon = nativeImage.createFromDataURL(badge) + } catch {} + } + const osN = new Notification(opts) liveNotifications.add(osN) const cleanupN = () => liveNotifications.delete(osN) osN.on("click", () => { @@ -544,34 +677,45 @@ const notificationCenter = createNotificationCenter({ osN.on("close", cleanupN) osN.show() } + + // Update the dock favicon overlay + warm the banner-icon cache — fire-and-forget so a + // slow favicon fetch can never delay or swallow the notification above. + void syncDockIcon() }, }) setInterval(() => notificationCenter.reconcile(), 5000) app.whenReady().then(() => { updateBadge() // restore dock badge from persisted unread - setTimeout(() => notificationCenter.reconcile(), 1000) + setTimeout(() => { + notificationCenter.reconcile() + syncDockIcon() // restore dock favicon overlay once the chrome renderer can composite + }, 1000) }) ipcMain.handle("cdp:get-notifications", () => notificationCenter.list()) ipcMain.handle("cdp:mark-notification-read", (_, id) => { const list = notificationCenter.markRead(id) updateBadge() + syncDockIcon() return list }) ipcMain.handle("cdp:mark-notification-unread", (_, id) => { const list = notificationCenter.markUnread(id) updateBadge() + syncDockIcon() return list }) ipcMain.handle("cdp:mark-notifications-read", () => { const list = notificationCenter.markAllRead() updateBadge() + syncDockIcon() return list }) ipcMain.handle("cdp:clear-notifications", () => { const list = notificationCenter.clear() updateBadge() + syncDockIcon() return list })