From 88b6fd714620f509715c364da537a0a1c0d632d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=E1=BB=87n=20Thanh=20Nguy=E1=BB=85n?= Date: Mon, 8 Jun 2026 16:45:49 +0700 Subject: [PATCH 1/4] feat(notifications): keep background tabs alive + favicon on dock/banner (t066) Background tabs on the remote browser silently stopped delivering notifications: Chromium freezes idle background tabs (~5 min), which pauses the page JS the capture script hooks (window.Notification), so only the active tab kept notifying. - Keep-alive: the side-channel now sends Page.setWebLifecycleState active on attach and re-applies it every reconcile. This un-freezes the tab without making it visible (per the CDP spec the state only governs freeze, not document.visibilityState), so Slack still treats the tab as hidden and keeps firing desktop notifications. Lives in core/ so the headless web server benefits too. - Favicon: the OS notification banner and the macOS dock icon now carry the source app's favicon (newest-unread; cleared when unread -> 0). dockOverlayIcon(list) is pure; main fetches the favicon bytes (no CORS wall) and composites in the renderer (decodes .ico, no canvas taint). Service-worker push capture (registration.showNotification, a separate realm) is out of scope here -> t067. --- CLAUDE.md | 2 + core/notifications-sidechain.js | 29 +++- core/notifications-sidechain.test.ts | 30 ++++ core/notifications.js | 10 ++ core/notifications.test.ts | 34 ++++ .../066-keep-notif-tabs-alive-dock-favicon.md | 57 +++++++ main.js | 150 +++++++++++++++++- 7 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 docs/tasks/066-keep-notif-tabs-alive-dock-favicon.md 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 }) From be78a458642f3330a305e68d82098ddb26512c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=E1=BB=87n=20Thanh=20Nguy=E1=BB=85n?= Date: Mon, 8 Jun 2026 16:45:57 +0700 Subject: [PATCH 2/4] feat(notifications): capture service-worker push notifications for slack (t067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack delivers many notifications from its service worker's push handler via registration.showNotification — a realm the page hook (window.Notification) can't reach, so they were silently missed. - The side-channel now also attaches to a matching service_worker target (Slack adapter swScript) and injects inject/slack-sw-notify.js via Runtime.evaluate (a worker has no Page domain, so no document-start hook and no t066 keep-alive), patching ServiceWorkerRegistration.prototype.showNotification to ship the same __cdpNotify toasts. - The single Slack SW serves every workspace (origin-level), so the SW URL has no team id; the script derives the per-workspace groupKey from the notification payload (defensive probe, logged once for HITL). Known gap: a worker that spins up fresh on a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier). Documented in docs/tasks/067; a hardened version would use a browser-level Target.setAutoAttach waitForDebuggerOnStart. Stacked on t066 (#4) — merge that first. --- CLAUDE.md | 6 +- core/notifications-sidechain.js | 55 ++++++++- core/notifications-sidechain.test.ts | 79 +++++++++++++ docs/tasks/067-service-worker-push-capture.md | 49 ++++++++ inject/slack-sw-notify.js | 106 ++++++++++++++++++ 5 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 docs/tasks/067-service-worker-push-capture.md create mode 100644 inject/slack-sw-notify.js diff --git a/CLAUDE.md b/CLAUDE.md index 974c04b..3e4861a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +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 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. +- **Service-worker push capture (t067, Slack)**: Slack delivers many notifications from its service worker's `push` handler via `registration.showNotification` — a separate realm the page hook can't reach. The side-channel now also attaches to the matching `service_worker` target (a Slack adapter `swScript`) and injects `inject/slack-sw-notify.js` via `Runtime.evaluate` (a worker has no `Page` domain, so no document-start hook + no keep-alive), patching `ServiceWorkerRegistration.prototype.showNotification` to ship the same `__cdpNotify` toasts. The single Slack SW serves every workspace (origin-level), so the SW URL has no team id — the script derives the per-workspace `groupKey` from the notification payload (defensive probe, logged once for HITL tightening). **Known gap:** a worker that spins up fresh on a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier; a hardened version would use a browser-level `Target.setAutoAttach({waitForDebuggerOnStart:true})`). The t066 keep-alive keeps the registration warm enough to stay listed across reconciles in the common case. - **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`. @@ -86,7 +87,8 @@ cdp-browser/ ├── inject/ │ ├── teams-notify.js # MutationObserver capture script injected into Teams pages │ ├── outlook-notify.js # MutationObserver capture for OWA NotificationPane; ships activate intent -│ └── slack-notify.js # Web Notifications API hijack (no in-app toast); forces permission granted, ships per-channel spa-link (t064) +│ ├── slack-notify.js # Web Notifications API hijack (no in-app toast); forces permission granted, ships per-channel spa-link (t064) +│ └── slack-sw-notify.js # Service-worker realm: patches ServiceWorkerRegistration.prototype.showNotification to capture SW push notifications (t067) ├── scripts/ │ └── install-local.sh # Build + install to /Applications (strips quarantine) ├── docs/ diff --git a/core/notifications-sidechain.js b/core/notifications-sidechain.js index 0ef6229..28c80a9 100644 --- a/core/notifications-sidechain.js +++ b/core/notifications-sidechain.js @@ -51,6 +51,12 @@ const ADAPTERS = [ // key from the Tab's URL team id instead — one Tab per workspace, so the URL is the // authoritative workspace identity (more durable than the in-page capture script). groupKey: slackGroupKey, + // Slack delivers many notifications from its service worker's `push` handler + // (`registration.showNotification`), a realm the page hook can't reach. `swScript` + // is injected into the matching service_worker target to patch showNotification there + // and ship the same `__cdpNotify` toasts (t067). The SW URL carries no team id, so the + // script derives the per-workspace groupKey from the notification payload instead. + swScript: "slack-sw-notify.js", }, ] @@ -67,6 +73,15 @@ function createNotificationCenter(deps) { if (!sourceCache.has(adapter.name)) sourceCache.set(adapter.name, readInject(adapter.script)) return sourceCache.get(adapter.name) } + // Service-worker capture script, memoized per adapter (separate cache key so it never + // collides with the page `script`). Only adapters that declare `swScript` have one. + const swSourceCache = new Map() + const swSourceFor = (adapter) => { + if (!swSourceCache.has(adapter.name)) { + swSourceCache.set(adapter.name, readInject(adapter.swScript)) + } + return swSourceCache.get(adapter.name) + } const seeded = load() let notifications = Array.isArray(seeded) ? seeded : [] @@ -144,6 +159,35 @@ function createNotificationCenter(deps) { cdp("Runtime.evaluate", { expression: sourceFor(adapter) }) keepAlive() }) + wireToastAndDrop(ws, target) + } + + // Service-worker side-channel (t067). Slack/Teams/Outlook deliver many notifications from + // their service worker's `push` handler via `registration.showNotification` — a realm the + // page hook (`window.Notification`) can't reach. A service_worker target supports Runtime + // (not Page), so we patch via a one-shot Runtime.evaluate into the running worker rather + // than Page.addScriptToEvaluateOnNewDocument. Best-effort: a worker that spins up fresh on + // a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier + // here). The page keep-alive (t066) keeps the registration warm, which keeps the worker + // listed in /json across reconciles. No keep-alive on the worker itself (no web lifecycle). + function attachServiceWorker(target) { + const adapter = adapterFor(target.url) + if (!adapter?.swScript || !target.webSocketDebuggerUrl) return + const ws = new WebSocketCtor(target.webSocketDebuggerUrl) + let cmdId = 1 + const cdp = (method, params) => + ws.send(JSON.stringify({ id: cmdId++, method, params: params || {} })) + sideChannels.set(target.id, { ws, keepAlive: () => {} }) + ws.on("open", () => { + cdp("Runtime.enable") + cdp("Runtime.addBinding", { name: NOTIFY_BINDING }) + cdp("Runtime.evaluate", { expression: swSourceFor(adapter) }) + }) + wireToastAndDrop(ws, target) + } + + // Shared toast ingest + self-removal wiring for both the page and service-worker channels. + function wireToastAndDrop(ws, target) { ws.on("message", (data) => { try { const msg = JSON.parse(data.toString()) @@ -172,8 +216,10 @@ 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)) + const pages = list.filter((t) => t.type === "page" && adapterFor(t.url)) + // Service-worker targets whose adapter declares a swScript (t067). + const workers = list.filter((t) => t.type === "service_worker" && adapterFor(t.url)?.swScript) + const liveIds = new Set([...pages, ...workers].map((t) => t.id)) for (const [id, { ws }] of sideChannels) { if (!liveIds.has(id)) { try { @@ -182,9 +228,10 @@ function createNotificationCenter(deps) { sideChannels.delete(id) } } - for (const t of matched) if (!sideChannels.has(t.id)) attach(t) + for (const t of pages) if (!sideChannels.has(t.id)) attach(t) + for (const t of workers) if (!sideChannels.has(t.id)) attachServiceWorker(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). + // re-frozen a backgrounded Tab since the last pass (t066). SW channels no-op. for (const [, ch] of sideChannels) ch.keepAlive() } diff --git a/core/notifications-sidechain.test.ts b/core/notifications-sidechain.test.ts index 3e6e50e..abc3bd0 100644 --- a/core/notifications-sidechain.test.ts +++ b/core/notifications-sidechain.test.ts @@ -338,6 +338,85 @@ describe("slack adapter — per-workspace grouping (t064)", () => { }) }) +describe("service-worker capture (t067)", () => { + const slackSwTarget = (id = "sw1", over = {}) => ({ + id, + type: "service_worker" as const, + url: "https://app.slack.com/service-worker.js", + webSocketDebuggerUrl: `ws://host/devtools/worker/${id}`, + ...over, + }) + + it("attaches to a service_worker target whose adapter declares a swScript and injects it via Runtime.evaluate (no Page domain)", async () => { + const { center } = makeCenter() + await center.reconcile([slackSwTarget()]) + expect(FakeWs.instances).toHaveLength(1) + const ws = FakeWs.instances[0] + ws.open() + const methods = ws.sent.map((m) => m.method) + expect(methods).toContain("Runtime.enable") + expect(methods).toContain("Runtime.addBinding") + expect(methods).toContain("Runtime.evaluate") + expect(methods).not.toContain("Page.enable") + expect(methods).not.toContain("Page.addScriptToEvaluateOnNewDocument") + const evalCmd = ws.sent.find((m) => m.method === "Runtime.evaluate") + expect(evalCmd.params.expression).toContain("slack-sw-notify.js") + }) + + it("does not attach to a service_worker whose adapter has no swScript (Teams)", async () => { + const { center } = makeCenter() + await center.reconcile([ + { + id: "tsw", + type: "service_worker", + url: "https://teams.microsoft.com/sw.js", + webSocketDebuggerUrl: "ws://host/devtools/worker/tsw", + }, + ]) + expect(FakeWs.instances).toHaveLength(0) + }) + + it("ingests a toast from the SW channel, stamped with the slack adapter + payload groupKey", async () => { + const { center } = makeCenter() + await center.reconcile([slackSwTarget()]) + const ws = FakeWs.instances[0] + ws.open() + // SW URL has no team id, so the worker script supplies the per-workspace groupKey. + ws.notify({ id: "swn", title: "@bob: ping", groupKey: "slack:T999" }) + expect(center.list()[0].adapter).toBe("slack") + expect(center.list()[0].groupKey).toBe("slack:T999") + }) + + it("never sends keep-alive on a SW channel (no web lifecycle on a worker)", async () => { + const { center } = makeCenter() + await center.reconcile([slackSwTarget()]) + const ws = FakeWs.instances[0] + ws.open() + await center.reconcile([slackSwTarget()]) // triggers the keep-alive re-apply pass + expect(ws.sent.filter((m) => m.method === "Page.setWebLifecycleState")).toHaveLength(0) + }) + + it("drops the SW channel when the worker vanishes", async () => { + const { center } = makeCenter() + await center.reconcile([slackSwTarget()]) + const ws = FakeWs.instances[0] + await center.reconcile([]) + expect(ws.closed).toBe(true) + }) + + it("attaches both the page and the SW channel for the same workspace", async () => { + const { center } = makeCenter() + const slackPage = { + id: "p1", + type: "page" as const, + url: "https://app.slack.com/client/T1/C1", + webSocketDebuggerUrl: "ws://host/devtools/page/p1", + } + await center.reconcile([slackPage, slackSwTarget()]) + expect(FakeWs.instances).toHaveLength(2) + }) +}) + describe("store mutations + persistence", () => { async function seeded() { const ctx = makeCenter() diff --git a/docs/tasks/067-service-worker-push-capture.md b/docs/tasks/067-service-worker-push-capture.md new file mode 100644 index 0000000..cd9a914 --- /dev/null +++ b/docs/tasks/067-service-worker-push-capture.md @@ -0,0 +1,49 @@ +# 067 — capture service-worker push notifications (Slack) + +- **Status:** in-progress +- **Mode:** HITL +- **Estimate:** 1d +- **Depends on:** 066 (keep-alive keeps the SW registration warm) +- **Blocks:** none + +## Goal + +The page hook (`window.Notification`, `slack-notify.js`) only sees notifications Slack fires +from the **page** realm. Slack also delivers notifications from its **service worker's** +`push` handler via `registration.showNotification(...)`, a separate realm the page script +can't reach — these were silently missed. After this task the side-channel also attaches to +the matching `service_worker` target and patches `showNotification` there, shipping the same +`__cdpNotify` toasts so SW-delivered notifications are captured too. + +## Why now + +Builds directly on t066: once background tabs stay alive, the remaining gap is the +SW-`push`-only deliveries. This closes the last "notification didn't show up" hole. + +## Acceptance criteria + +- [ ] `reconcile` attaches a side-channel to a `service_worker` target whose adapter declares `swScript`. +- [ ] The SW channel injects via `Runtime.evaluate` (no `Page` domain on a worker). +- [ ] SW channels never receive the t066 page keep-alive (`setWebLifecycleState`). +- [ ] A SW-`__cdpNotify` toast is ingested + grouped + fired through the same store path. +- [ ] Per-workspace `groupKey` comes from the payload (the SW URL has no team id). +- [ ] SW channel is dropped when the worker disappears from `/json`. + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [x] sidechain SW attach — attaches to a `service_worker` w/ swScript, evaluates the SW script, no Page domain, no keep-alive, drops on vanish, page+SW coexist. + +### Layer 2 — Manual smoke (CDP/IPC) — **REQUIRED, blind-spots below** + +- [ ] With Slack push enabled on the remote browser, trigger a SW push (close all Slack tabs' focus / use a push that routes through the SW) → notification is captured. +- [ ] Inspect the one-time `[cdp-sw-notify] sample options:` log in the worker console; **tighten `TEAM_RE`/`probe` keys to the real payload** if workspace grouping is wrong. +- [ ] Confirm per-workspace `groupKey` resolves (else all workspaces merge under the SW origin). + +## Design notes + +- **Contracts changed:** adapter gains optional `swScript`; `reconcile` matches `service_worker` targets; new `attachServiceWorker` path + `inject/slack-sw-notify.js`. +- **Known limitation (documented, not fixed here):** a worker that spins up fresh on a push and fires `showNotification` *before* the next 5s reconcile attaches is missed — there's no SW-start barrier. A hardened version would use a browser-level CDP session with `Target.setAutoAttach({ waitForDebuggerOnStart: true })` to attach + inject before the worker runs. Deferred; the t066 page keep-alive keeps the registration warm enough to be listed across reconciles in the common case. +- **Payload shape is a guess:** Slack's SW push `data` isn't publicly documented; `slack-sw-notify.js` probes defensively and logs a sample once for HITL tightening. +- **New ADR needed?** no — extends ADR-0003 (notifications side-channel). diff --git a/inject/slack-sw-notify.js b/inject/slack-sw-notify.js new file mode 100644 index 0000000..da763ff --- /dev/null +++ b/inject/slack-sw-notify.js @@ -0,0 +1,106 @@ +// Injected into Slack's SERVICE WORKER target (t067), not the page. Slack delivers many +// notifications from its service worker's `push` handler via +// `self.registration.showNotification(...)` — a realm the page hook (slack-notify.js's +// `window.Notification` patch) can't reach. This script patches +// `ServiceWorkerRegistration.prototype.showNotification` in the worker's global scope and +// ships the same `__cdpNotify` toast the side-channel ingests. +// +// EXPERIMENTAL / HITL: Slack's push payload shape is not publicly documented, so the +// team/channel extraction below probes the common carriers defensively and degrades to a +// tab-/origin-only toast when it can't find them. Verify against a live Slack SW push and +// tighten the probes (the captured `options.data` is logged to the worker console once). +// +// Realm notes: +// - No `window` / `document` here — only `self`, `self.registration`, the global +// `ServiceWorkerRegistration`, and the `__cdpNotify` binding the side-channel registers +// via Runtime.addBinding before this runs. +// - The single Slack SW serves EVERY workspace (app.slack.com origin-level), so the SW URL +// carries no team id. The per-workspace groupKey must come from the payload, not the URL. +// - We deliberately let the real showNotification still run so the user isn't left with a +// silently-swallowed push (the remote browser's own toast is harmless and offscreen); +// capture is purely additive. +;(() => { + if (self.__cdpSwNotifyArmed) return + self.__cdpSwNotifyArmed = true + + let seq = 0 + let loggedShape = false + + const CHANNEL_RE = /\b([CDG][A-Z0-9]{6,})\b/ + const TEAM_RE = /\b([TE][A-Z0-9]{6,})\b/ + + // Pull the first well-formed id matching `re` out of a grab-bag of probe strings drawn + // from the notification options (tag + data, object or scalar, plus a JSON dump). + const probe = (opts, re) => { + if (!opts || typeof opts !== "object") return null + const probes = [] + if (opts.tag != null) probes.push(String(opts.tag)) + const d = opts.data + if (d != null) { + if (typeof d === "object") { + for (const k of ["team", "teamId", "team_id", "channel", "channelId", "channel_id", "id"]) { + if (d[k] != null) probes.push(String(d[k])) + } + try { + probes.push(JSON.stringify(d)) + } catch { + /* circular — skip */ + } + } else { + probes.push(String(d)) + } + } + for (const p of probes) { + const m = p.match(re) + if (m) return m[1] + } + return null + } + + const capture = (title, opts) => { + if (!loggedShape) { + loggedShape = true + try { + // One-time aid for tightening the probes against the real payload (HITL). + console.log("[cdp-sw-notify] sample options:", JSON.stringify(opts)) + } catch {} + } + const team = probe(opts, TEAM_RE) + const channel = probe(opts, CHANNEL_RE) + const body = opts && typeof opts.body === "string" ? opts.body : "" + const payload = { + id: `slack-sw:${team || "?"}:${Date.now()}:${seq++}`, + source: "Slack", + title: title != null ? String(title) : "", + body, + // Per-workspace bucket from the payload (the SW URL has no team id). When absent the + // side-channel falls back to the SW origin — all workspaces merge, but still captured. + groupKey: team ? `slack:${team}` : undefined, + activate: channel && team ? { type: "spa-link", url: `/client/${team}/${channel}` } : null, + ts: Date.now(), + } + try { + self.__cdpNotify(JSON.stringify(payload)) + } catch { + /* binding not registered (shouldn't happen) */ + } + } + + const proto = + typeof ServiceWorkerRegistration !== "undefined" && ServiceWorkerRegistration.prototype + if (!proto || typeof proto.showNotification !== "function") return + const real = proto.showNotification + proto.showNotification = function patchedShowNotification(title, opts, ...rest) { + try { + capture(title, opts) + } catch { + /* never break the worker */ + } + // Still call through so the push handler's contract is honoured. + try { + return real.call(this, title, opts, ...rest) + } catch { + return Promise.resolve() + } + } +})() From 05bdf54c6373d072b1e2279fce92ec458c6278e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=E1=BB=87n=20Thanh=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 17:17:35 +0700 Subject: [PATCH 3/4] feat(clipboard): paste local files (video/doc) into remote, not just images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a copied file (e.g. a video from Finder) pasted the file's icon thumbnail instead of the file. Root cause: the paste path assumed clipboard *image bits* and never a *file reference* — clipboard.readImage() returns a copied file's Quick Look icon (non-empty), so that branch won and pasteImage shipped the icon PNG; on web the native paste handler filtered items to image/ only, dropping video/*. - core/clipboard.js: add pure mimeForName (extension -> MIME); convert the module to CommonJS so the CJS main process can require it (aligns with the rest of core/). - main.js: new cdp:read-clipboard-files IPC reads the real file via the clipboard's public.file-url and returns { name, type, dataUrl } (150 MB cap, main-process fs, no CORS wall). - remote-page.ts: generalize pasteImage -> pasteFile(dataUrl, name, type), preserving the real name + MIME so upload targets accept a video; pasteImage is now a thin wrapper. - app.tsx: Cmd+V tries files -> image bits -> text; web paste accepts any file kind off the native paste event, not just images. - preload.js / vite-env.d.ts / cdp-web-transport.ts: wire/stub readClipboardFiles. Also bundles the notification favicon/dock-icon assets (build/notif-icons, package.json packaging allowlist) and the accompanying notifications-sidechain / notifications refactor. --- CLAUDE.md | 4 +- build/notif-icons/outlook.png | Bin 0 -> 20645 bytes build/notif-icons/slack.png | Bin 0 -> 9070 bytes build/notif-icons/teams.png | Bin 0 -> 10140 bytes core/clipboard.js | 62 +++++++++++++- core/clipboard.test.mjs | 21 +++++ core/notifications-sidechain.js | 4 +- core/notifications.js | 10 --- core/notifications.test.ts | 34 -------- main.js | 138 +++++++++++++++++++++----------- package.json | 2 + preload.js | 1 + src/app.tsx | 36 ++++++--- src/lib/CLAUDE.md | 2 +- src/lib/cdp-web-transport.ts | 1 + src/lib/remote-page.test.ts | 13 +++ src/lib/remote-page.ts | 21 ++++- src/vite-env.d.ts | 5 ++ 18 files changed, 239 insertions(+), 115 deletions(-) create mode 100644 build/notif-icons/outlook.png create mode 100644 build/notif-icons/slack.png create mode 100644 build/notif-icons/teams.png diff --git a/CLAUDE.md b/CLAUDE.md index 3e4861a..59bbefd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **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`, 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`. +- **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`. **Local→remote file (video/doc, t068)**: a copied *file* (not raw image bits) is read as the actual file — `clipboard.readImage()` only yields the file's icon thumbnail, so Electron `window.cdp.readClipboardFiles()` reads the path from the clipboard's `public.file-url` and returns `{ name, type, dataUrl }` (mime from `core/clipboard.js` `mimeForName`; main has fs access, no CORS wall; 150 MB cap). The Cmd+V handler tries files → image bits → text in that order; web reads any file kind off the native `paste` event (not just `image/`). Both call `RemotePage.pasteFile(dataUrl, name, type)`, which synthesizes the same paste event but preserves the real name + MIME so upload targets accept a video. `pasteImage` is now a thin wrapper over `pasteFile`. **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. - **Service-worker push capture (t067, Slack)**: Slack delivers many notifications from its service worker's `push` handler via `registration.showNotification` — a separate realm the page hook can't reach. The side-channel now also attaches to the matching `service_worker` target (a Slack adapter `swScript`) and injects `inject/slack-sw-notify.js` via `Runtime.evaluate` (a worker has no `Page` domain, so no document-start hook + no keep-alive), patching `ServiceWorkerRegistration.prototype.showNotification` to ship the same `__cdpNotify` toasts. The single Slack SW serves every workspace (origin-level), so the SW URL has no team id — the script derives the per-workspace `groupKey` from the notification payload (defensive probe, logged once for HITL tightening). **Known gap:** a worker that spins up fresh on a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier; a hardened version would use a browser-level `Target.setAutoAttach({waitForDebuggerOnStart:true})`). The t066 keep-alive keeps the registration warm enough to stay listed across reconciles in the common case. - **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. @@ -76,7 +76,7 @@ cdp-browser/ │ ├── frame-throttle.js # Pure screencast rate throttle (createFrameThrottle, DI clock; fresh-frame-wins) │ ├── frame-ack-gate.js # Pure one-in-flight gate + watchdog for WS paint-ack backpressure (t056) │ ├── quality-tier.js # Pure Sharp/Balanced/Snappy screencast preset owner (tierToParams/DEFAULT_TIER/parseTier) -│ ├── clipboard.js # Pure clipboard helpers: grantPermissions enum-fallback builders + selectPasteRoute (t065) +│ ├── clipboard.js # Pure clipboard helpers: grantPermissions enum-fallback builders + selectPasteRoute (t065) + mimeForName (extension→MIME for file paste) │ └── crypto-envelope.js # Server-side AES-256-GCM seal/open (E2E mode); mirrors src/lib/crypto-envelope.ts ├── web/ │ └── server.mjs # Web build backend: serves dist/ + SSE/POST/WS proxy + streaming input (no Electron). See ADR-0006, ADR-0007 diff --git a/build/notif-icons/outlook.png b/build/notif-icons/outlook.png new file mode 100644 index 0000000000000000000000000000000000000000..53ebc5bad5fa222bfacd1401476f789a07a2042b GIT binary patch literal 20645 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 Q0{{R307*qoM6N<$f;t<3CIA2c literal 0 HcmV?d00001 diff --git a/build/notif-icons/slack.png b/build/notif-icons/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..3f4292e3e7144f92b2580922d6e987cd0bae932f GIT binary patch literal 9070 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 literal 0 HcmV?d00001 diff --git a/build/notif-icons/teams.png b/build/notif-icons/teams.png new file mode 100644 index 0000000000000000000000000000000000000000..de70187744db4df651057175e51047dda4e91ce1 GIT binary patch literal 10140 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 J{l+Zxe*moBJk { + it("maps common video extensions", () => { + expect(mimeForName("clip.mp4")).toBe("video/mp4") + expect(mimeForName("movie.MOV")).toBe("video/quicktime") + expect(mimeForName("a.webm")).toBe("video/webm") + }) + + it("maps common image extensions", () => { + expect(mimeForName("pic.png")).toBe("image/png") + expect(mimeForName("photo.JPG")).toBe("image/jpeg") + expect(mimeForName("anim.gif")).toBe("image/gif") + }) + + it("falls back to octet-stream for unknown or missing extensions", () => { + expect(mimeForName("noext")).toBe("application/octet-stream") + expect(mimeForName("archive.zzz")).toBe("application/octet-stream") + expect(mimeForName("")).toBe("application/octet-stream") + }) +}) + describe("clipboard permissions", () => { describe("buildClipboardPermissionsModern", () => { it("returns modern permission names without origin", () => { diff --git a/core/notifications-sidechain.js b/core/notifications-sidechain.js index 28c80a9..6b67207 100644 --- a/core/notifications-sidechain.js +++ b/core/notifications-sidechain.js @@ -45,7 +45,9 @@ const ADAPTERS = [ name: "slack", script: "slack-notify.js", match: (h) => /(^|\.)slack\.com$/.test(h), - iconUrl: "https://a.slack-edge.com/80588/marketing/img/icons/favicon-32-electron.png", + // Renderer-bell icon only (the OS banner + dock use bundled build/notif-icons/slack.png). + // The old favicon-32-electron.png path 404/403'd; app-256.png is a stable Slack logo. + iconUrl: "https://a.slack-edge.com/80588/img/icons/app-256.png", // Slack runs every workspace under one origin (app.slack.com), so the default // per-origin grouping would merge all workspaces into one badge. Derive the group // key from the Tab's URL team id instead — one Tab per workspace, so the URL is the diff --git a/core/notifications.js b/core/notifications.js index 64defb9..60766bf 100644 --- a/core/notifications.js +++ b/core/notifications.js @@ -99,15 +99,6 @@ 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 = {} @@ -124,7 +115,6 @@ module.exports = { slackGroupKey, ingest, shouldNotifyOs, - dockOverlayIcon, markRead, markUnread, markAllRead, diff --git a/core/notifications.test.ts b/core/notifications.test.ts index d526448..b9d0b37 100644 --- a/core/notifications.test.ts +++ b/core/notifications.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest" // CommonJS module shared with main.js (which can't import src/lib ESM). import { - dockOverlayIcon, groupKeyFor, ingest, markAllRead, @@ -183,36 +182,3 @@ 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/main.js b/main.js index ecb33b2..a34f051 100644 --- a/main.js +++ b/main.js @@ -19,6 +19,7 @@ const WebSocket = require("ws") const { emulatedMediaParams } = require("./core/theme-emulation") const { createSettingsStore } = require("./core/settings-store") const endpoints = require("./core/cdp-endpoints") +const { mimeForName: clipboardMime } = require("./core/clipboard") const { tierToParams, DEFAULT_TIER } = require("./core/quality-tier") // The window is a BaseWindow composed of a chrome view (the React UI, full @@ -207,6 +208,42 @@ ipcMain.handle("cdp:read-clipboard-image", () => { return img.isEmpty() ? null : img.toDataURL() }) +// Real files on the clipboard (e.g. a video copied in Finder) — read the actual bytes +// rather than clipboard.readImage(), which only yields the file's icon/thumbnail. Returns +// [{ name, type, dataUrl }]; empty when the clipboard holds no file reference. Reading +// happens in main (full fs access, no browser CORS/permission wall). +const MAX_CLIPBOARD_FILE_BYTES = 150 * 1024 * 1024 // 150 MB — guards against OOM on huge media +ipcMain.handle("cdp:read-clipboard-files", () => { + let paths = [] + try { + // macOS: a single copied file exposes public.file-url (file:///…); read it directly. + const fileUrl = clipboard.read("public.file-url") + if (fileUrl) { + const p = decodeURIComponent(fileUrl.replace(/^file:\/\//, "")) + if (p) paths = [p] + } + } catch { + // format absent on this platform / clipboard — fall through to empty + } + const out = [] + for (const p of paths) { + try { + const stat = fs.statSync(p) + if (!stat.isFile() || stat.size > MAX_CLIPBOARD_FILE_BYTES) continue + const buf = fs.readFileSync(p) + const type = clipboardMime(path.basename(p)) + out.push({ + name: path.basename(p), + type, + dataUrl: `data:${type};base64,${buf.toString("base64")}`, + }) + } catch { + // unreadable path — skip + } + } + return out +}) + ipcMain.handle("cdp:list-tabs", async () => { try { const { url, method } = endpoints.list(cdpHost, cdpPort) @@ -479,7 +516,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, dockOverlayIcon } = require("./core/notifications") +const { shouldNotifyOs } = require("./core/notifications") const { createNotificationCenter } = require("./core/notifications-sidechain") // Persisted store (separate from settings.json to keep that file lean). @@ -518,38 +555,50 @@ function baseIcon() { 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 = "" +// Bundled per-adapter app icons (Slack/Teams/Outlook), keyed by adapter name. Local files — +// NO network: remote favicon URLs were unreliable (Slack's returned HTTP 403, corporate +// proxies black-holed the fetch, and the first notification always missed the cache). Local +// PNGs render on the very first notification and can't hang. See build/notif-icons/. +const NOTIF_ICONS_DIR = path.join(__dirname, "build", "notif-icons") +// Human label per adapter for the macOS notification subtitle (text fallback when the OS +// banner ignores the custom icon). +const NOTIF_APP_LABELS = { slack: "Slack", teams: "Microsoft Teams", outlook: "Outlook" } +const localIconImageCache = new Map() // adapter -> NativeImage | null +function localIconImage(adapter) { + if (!adapter) return null + if (localIconImageCache.has(adapter)) return localIconImageCache.get(adapter) + let img = null 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")}` + const p = path.join(NOTIF_ICONS_DIR, `${adapter}.png`) + if (fs.existsSync(p)) { + const candidate = nativeImage.createFromPath(p) + if (!candidate.isEmpty()) img = candidate } } catch {} - faviconDataUrlCache.set(url, out) + localIconImageCache.set(adapter, img) + return img +} +const localIconDataUrlCache = new Map() // adapter -> dataURL | "" +function localIconDataUrl(adapter) { + if (!adapter) return "" + if (localIconDataUrlCache.has(adapter)) return localIconDataUrlCache.get(adapter) + let out = "" + try { + const p = path.join(NOTIF_ICONS_DIR, `${adapter}.png`) + if (fs.existsSync(p)) out = `data:image/png;base64,${fs.readFileSync(p).toString("base64")}` + } catch {} + localIconDataUrlCache.set(adapter, 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) { +// In the renderer: draw base app icon + the adapter icon in the bottom-right corner. +// Returns the composited dock PNG data URL, or null on failure. Inputs are local data URLs, +// so the canvas is never cross-origin-tainted; a per-image timeout prevents any hang. +async function composeDockIcon(iconDataUrl) { const wc = chromeWc() const base = baseIcon() - if (!wc || !base || !faviconUrl) return null + if (!wc || !base || !iconDataUrl) 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) @@ -558,7 +607,7 @@ async function composeDockBadge(faviconUrl) { img.src = src }) try { - const [base, fav] = await Promise.all([load(${JSON.stringify(base)}), load(${JSON.stringify(faviconUrl)})]) + const [base, fav] = await Promise.all([load(${JSON.stringify(base)}), load(${JSON.stringify(iconDataUrl)})]) if (!base || !fav) return null const S = base.naturalWidth || 1024 const c = document.createElement("canvas"); c.width = S; c.height = S @@ -579,9 +628,7 @@ async function composeDockBadge(faviconUrl) { 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") } + return c.toDataURL("image/png") } catch { return null } })()` try { @@ -604,21 +651,15 @@ function clearDockIcon() { } 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. +// Reconcile the dock icon with the store: overlay the newest-unread app's icon, or restore +// the plain icon when nothing is unread. Fire-and-forget — never awaited on a path that +// gates a notification. Uses bundled local icons, so it can't hang on the network. 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) + const newest = notificationCenter.list().find((n) => !n.read) + const iconDataUrl = newest ? localIconDataUrl(newest.adapter) : "" + const dock = iconDataUrl ? await composeDockIcon(iconDataUrl) : null + if (dock) setDockIcon(dock) else clearDockIcon() } catch {} } @@ -657,12 +698,15 @@ const notificationCenter = createNotificationCenter({ Notification.isSupported() ) { 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 {} - } + // Tell the user WHICH app pinged them, two ways for robustness: + // - icon: bundled local adapter logo (Slack/Teams/Outlook). Works on Windows/Linux and + // in macOS Notification Center; macOS banners may fall back to the app icon. + // - subtitle (macOS): the app label in TEXT — always visible even if the icon is + // suppressed, so "Slack" vs "Teams" is never ambiguous. + const img = localIconImage(entry.adapter) + if (img) opts.icon = img + const label = NOTIF_APP_LABELS[entry.adapter] + if (label) opts.subtitle = label const osN = new Notification(opts) liveNotifications.add(osN) const cleanupN = () => liveNotifications.delete(osN) diff --git a/package.json b/package.json index d2a9ac6..c38b6db 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "!core/**/*.test.js", "inject/**/*", "dist/**/*", + "build/icon.png", + "build/notif-icons/**/*", "!node_modules/**/*", "node_modules/ws/**/*" ], diff --git a/preload.js b/preload.js index e0bd0e0..0208ce2 100644 --- a/preload.js +++ b/preload.js @@ -23,6 +23,7 @@ contextBridge.exposeInMainWorld("cdp", { copyToClipboard: (text) => ipcRenderer.invoke("cdp:copy-to-clipboard", text), readClipboard: () => ipcRenderer.invoke("cdp:read-clipboard"), readClipboardImage: () => ipcRenderer.invoke("cdp:read-clipboard-image"), + readClipboardFiles: () => ipcRenderer.invoke("cdp:read-clipboard-files"), onSwipe: (cb) => ipcRenderer.on("cdp:swipe", (_, direction) => cb(direction)), // Pins getPins: () => ipcRenderer.invoke("cdp:get-pins"), diff --git a/src/app.tsx b/src/app.tsx index a0f27be..f78bbf8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1179,16 +1179,25 @@ export default function App() { if (caps.web) break e.preventDefault() e.stopPropagation() - // Electron: read the local clipboard in main (image first, then text) and inject. + // Electron: read the local clipboard in main and inject. Order matters: a copied + // *file* (e.g. a video from Finder) must be read as a real file — readClipboardImage + // would otherwise return the file's icon thumbnail and paste that instead. So: + // files → image bits → text. window.cdp - .readClipboardImage() - .then((dataUrl) => { - if (dataUrl) { - page.pasteImage(dataUrl) + .readClipboardFiles() + .then((files) => { + if (files && files.length) { + for (const f of files) page.pasteFile(f.dataUrl, f.name, f.type) return } - return window.cdp.readClipboard().then((text) => { - if (text) page.paste(text, { rich: false }) + return window.cdp.readClipboardImage().then((dataUrl) => { + if (dataUrl) { + page.pasteImage(dataUrl) + return + } + return window.cdp.readClipboard().then((text) => { + if (text) page.paste(text, { rich: false }) + }) }) }) .catch(() => { @@ -1238,16 +1247,17 @@ export default function App() { return const dt = e.clipboardData if (!dt) return - const imageItem = Array.from(dt.items).find( - (it) => it.kind === "file" && it.type.startsWith("image/"), - ) - if (imageItem) { - const file = imageItem.getAsFile() + // Any file (image, video, doc) — not just images. Preserve name + type so the + // remote upload target accepts a video instead of receiving a bare image. + const fileItem = Array.from(dt.items).find((it) => it.kind === "file") + if (fileItem) { + const file = fileItem.getAsFile() if (file) { e.preventDefault() const reader = new FileReader() reader.onload = () => { - if (typeof reader.result === "string") page.pasteImage(reader.result) + if (typeof reader.result === "string") + page.pasteFile(reader.result, file.name, file.type) } reader.readAsDataURL(file) return diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index 61ae936..eedbc11 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -4,7 +4,7 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire ## Modules -**`remote-page.ts`** — the Remote Page. `createRemotePage(transport)` wraps the CDP Transport seam into named intentions (`navigate`, `navigateSpa`, `openTeamsThread`, `back`, `forward`, `reload`, `selectAll`, `copySelection`, `getNavState`, `isLoading`, `paste`, `pasteImage`, `find`/`findStep`/`clearFind`) — `navigateSpa` drives client-side SPA routing (`pushState`+`popstate`, full-navigation fallback) for deep-opening, e.g. an Outlook message from a notification; `openTeamsThread` deep-opens a Teams conversation by clicking the chat row carrying the thread id (Teams has no URL route — see ADR-0003); `paste(text, {rich?})` inserts the local clipboard text into the remote focused element: `rich:false` (default) uses `Input.insertText` (plain, fires `input` events on React-controlled inputs); `rich:true` pre-seeds the remote clipboard via `Runtime.evaluate` and forwards Cmd+V so the page's `onpaste` handler runs; `pasteImage(dataUrl)` synthesizes a `paste` ClipboardEvent on the remote's focused element carrying the image as a `File` in a `DataTransfer` (rich editors — Slack, Gmail, Docs — read `clipboardData.files`); `find`/`findStep`/`clearFind` are the in-page find seam (t001) — they inject a per-document `window.__cdpFind` helper via `Runtime.evaluate` (`window.find` reports only a boolean, so the helper owns counting, stepping-with-wrap, scroll-into-view, and clearing) and report `{ total }` / `{ index }` via `returnByValue`; and the two subscription surfaces (`on` for typed events, `onFrame` for Screencast Frames). One registration on the raw transport; subscribers come and go — no re-registration, no leaks. Auto-acks every Screencast Frame before passing it to `onFrame` listeners. `forwardInput(InputIntent)` is the single Input Forwarding extension seam: new input kinds (IME, paste, drag) become new variants on `InputIntent` plus one `case` in `forwardInput`; no other interface changes. +**`remote-page.ts`** — the Remote Page. `createRemotePage(transport)` wraps the CDP Transport seam into named intentions (`navigate`, `navigateSpa`, `openTeamsThread`, `back`, `forward`, `reload`, `selectAll`, `copySelection`, `getNavState`, `isLoading`, `paste`, `pasteImage`, `pasteFile`, `find`/`findStep`/`clearFind`) — `navigateSpa` drives client-side SPA routing (`pushState`+`popstate`, full-navigation fallback) for deep-opening, e.g. an Outlook message from a notification; `openTeamsThread` deep-opens a Teams conversation by clicking the chat row carrying the thread id (Teams has no URL route — see ADR-0003); `paste(text, {rich?})` inserts the local clipboard text into the remote focused element: `rich:false` (default) uses `Input.insertText` (plain, fires `input` events on React-controlled inputs); `rich:true` pre-seeds the remote clipboard via `Runtime.evaluate` and forwards Cmd+V so the page's `onpaste` handler runs; `pasteFile(dataUrl, name, type)` synthesizes a `paste` ClipboardEvent on the remote's focused element carrying the file as a `File` in a `DataTransfer` (rich editors / upload surfaces — Slack, Gmail, Drive — read `clipboardData.files`), preserving the real name + MIME so a video/doc is accepted (not just an image); `pasteImage(dataUrl)` is a thin wrapper over it for raw image bits; `find`/`findStep`/`clearFind` are the in-page find seam (t001) — they inject a per-document `window.__cdpFind` helper via `Runtime.evaluate` (`window.find` reports only a boolean, so the helper owns counting, stepping-with-wrap, scroll-into-view, and clearing) and report `{ total }` / `{ index }` via `returnByValue`; and the two subscription surfaces (`on` for typed events, `onFrame` for Screencast Frames). One registration on the raw transport; subscribers come and go — no re-registration, no leaks. Auto-acks every Screencast Frame before passing it to `onFrame` listeners. `forwardInput(InputIntent)` is the single Input Forwarding extension seam: new input kinds (IME, paste, drag) become new variants on `InputIntent` plus one `case` in `forwardInput`; no other interface changes. **`tabs.ts`** — Tab ordering and lifecycle. `reconcile(order, remoteTabs)` merges the Remote Browser's tab list against the locally-owned order: existing tabs keep position, gone tabs drop out, new tabs append. `nextTab`/`prevTab` wrap around. `stripTitleBadge(title)` strips a leading `(N)` unread count that some apps (e.g. Teams) prepend to the document title — the app surfaces unread counts via its own tab badge, so the title shouldn't duplicate it. diff --git a/src/lib/cdp-web-transport.ts b/src/lib/cdp-web-transport.ts index 0ad62e7..d23b849 100644 --- a/src/lib/cdp-web-transport.ts +++ b/src/lib/cdp-web-transport.ts @@ -1205,6 +1205,7 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge // Web reads the clipboard from the native `paste` event (app.tsx), not here — the // async Clipboard API can't reliably read images on Safari/iPad. Stub returns null. readClipboardImage: async () => null, + readClipboardFiles: async () => [], onSwipe: () => {}, // no trackpad swipe over the web getPins: () => rest.getJson("/api/pins"), addPin: (pin) => rest.postJson("/api/pins/add", pin), diff --git a/src/lib/remote-page.test.ts b/src/lib/remote-page.test.ts index 6a79e46..fb1c428 100644 --- a/src/lib/remote-page.test.ts +++ b/src/lib/remote-page.test.ts @@ -368,6 +368,19 @@ describe("RemotePage clipboard paste", () => { expect(expr).toContain('ClipboardEvent("paste"') expect(expr).toContain("data:image/png;base64,ABC") }) + + it("pasteFile carries the file name and mime type into the synthesized File", () => { + const t = fakeTransport() + const page = createRemotePage(t.transport) + + page.pasteFile("data:video/mp4;base64,XYZ", "clip.mp4", "video/mp4") + + const expr = t.invoke.mock.calls[0][1].expression as string + expect(expr).toContain('ClipboardEvent("paste"') + expect(expr).toContain("data:video/mp4;base64,XYZ") + expect(expr).toContain("clip.mp4") + expect(expr).toContain("video/mp4") + }) }) describe("RemotePage screencast frames", () => { diff --git a/src/lib/remote-page.ts b/src/lib/remote-page.ts index 2ef449d..d0f7b72 100644 --- a/src/lib/remote-page.ts +++ b/src/lib/remote-page.ts @@ -243,6 +243,14 @@ export interface RemotePage { * `dataUrl` is a `data:image/...;base64,…` string. */ pasteImage(dataUrl: string): void + /** + * Pastes an arbitrary file (video, audio, doc, image) into the remote page's + * focused element by synthesizing a `paste` ClipboardEvent carrying the file as a + * `File` in a `DataTransfer`. Unlike `pasteImage` this preserves the original file + * name + MIME type so upload targets that sniff extension/type (Slack, Drive) accept + * it. `dataUrl` is a `data:;base64,…` string. + */ + pasteFile(dataUrl: string, name: string, type: string): void /** * In-page find (t001). The remote-side search is an injected per-document routine * (`window.find` reports only a boolean — it can't count or step deterministically), @@ -481,14 +489,19 @@ export function createRemotePage( } }, pasteImage(dataUrl) { - // Input.insertText can't carry images, so synthesize a paste event on the remote's - // focused element with a DataTransfer holding the image File — rich editors (Slack, - // Gmail, Docs) that listen for `paste` read it from clipboardData.files. + this.pasteFile(dataUrl, "pasted-image.png", "image/png") + }, + pasteFile(dataUrl, name, type) { + // Input.insertText can't carry binary, so synthesize a paste event on the remote's + // focused element with a DataTransfer holding the File — rich editors / upload + // surfaces (Slack, Gmail, Drive) that listen for `paste` read it from + // clipboardData.files. Name + type are preserved so the target accepts the file + // (a video needs its real extension/MIME, not a generic image). transport.invoke("Runtime.evaluate", { expression: `(async () => { const res = await fetch(${JSON.stringify(dataUrl)}); const blob = await res.blob(); - const file = new File([blob], "pasted-image.png", { type: blob.type || "image/png" }); + const file = new File([blob], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} || blob.type || "application/octet-stream" }); const dt = new DataTransfer(); dt.items.add(file); const el = document.activeElement || document.body; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 57337d1..191cc2e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -99,6 +99,11 @@ interface CdpBridge { readClipboard: () => Promise /** Electron-only: the local clipboard's image as a data URL, or null if none. */ readClipboardImage: () => Promise + /** + * Electron-only: real files referenced on the local clipboard (e.g. a video copied in + * Finder), read as `{ name, type, dataUrl }`. Empty when no file reference is present. + */ + readClipboardFiles: () => Promise> onSwipe: (cb: (direction: string) => void) => void // Pins getPins: () => Promise From c4876e8713050671bd262eddb03742cef11960d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=E1=BB=87n=20Thanh=20Nguy=E1=BB=85n?= Date: Wed, 10 Jun 2026 11:18:38 +0700 Subject: [PATCH 4/4] feat(clipboard): drag-and-drop files onto the canvas, streamed in chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping a copied video already attached the file but froze CDP, and pasting a video pasted the file's icon. Freeze root cause: dropFiles/pasteFile embedded the whole file's base64 in a single Runtime.evaluate — a 62 MB video became an ~86 MB source literal the remote renderer parsed in one tick, stalling the screencast that shares the CDP socket. Fix: stream the payload in ~2 MB base64 chunks (streamFiles -> window.__cdpFiles[key]) and reconstruct the File + dispatch in a final small evaluate (assembleFilesExpr). pasteFile/pasteImage/dropFiles are now async. - remote-page.ts: new dropFiles(specs, clientX, clientY) seam — maps the drop point through the Input-Forwarding coord resolver and synthesizes dragenter/dragover/drop DragEvents carrying the files in a DataTransfer on the element under the cursor. Chunked streaming shared with pasteFile. - viewport.tsx: drag handlers + drop-target overlay; reads dropped Files via FileReader (identical on Electron + web), 100 MB/file cap with a toast for skipped files, and a progress toast while streaming. A window-level guard preventDefaults file drags outside an editable field / local webview so an errant drop can't navigate the Electron window to file://. Clipboard paste of a file stays OS-dependent (a copied video may expose only an icon bitmap, no public.file-url), so drag-drop is the reliable path. Tests rewritten for the chunked protocol (payload rides chunk pushes, never the final dispatch). 636 passing, typecheck + Biome (changed) clean. --- CLAUDE.md | 4 +- src/app.tsx | 2 +- src/components/viewport.tsx | 114 +++++++++++++++++++++++++++++++++- src/lib/CLAUDE.md | 2 +- src/lib/remote-page.test.ts | 61 +++++++++++++----- src/lib/remote-page.ts | 120 +++++++++++++++++++++++++++++++----- 6 files changed, 268 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59bbefd..a7356c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **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`, 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`. **Local→remote file (video/doc, t068)**: a copied *file* (not raw image bits) is read as the actual file — `clipboard.readImage()` only yields the file's icon thumbnail, so Electron `window.cdp.readClipboardFiles()` reads the path from the clipboard's `public.file-url` and returns `{ name, type, dataUrl }` (mime from `core/clipboard.js` `mimeForName`; main has fs access, no CORS wall; 150 MB cap). The Cmd+V handler tries files → image bits → text in that order; web reads any file kind off the native `paste` event (not just `image/`). Both call `RemotePage.pasteFile(dataUrl, name, type)`, which synthesizes the same paste event but preserves the real name + MIME so upload targets accept a video. `pasteImage` is now a thin wrapper over `pasteFile`. **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`. +- **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`. **Local→remote file (video/doc, t068)**: a copied *file* (not raw image bits) is read as the actual file — `clipboard.readImage()` only yields the file's icon thumbnail, so Electron `window.cdp.readClipboardFiles()` reads the path from the clipboard's `public.file-url` and returns `{ name, type, dataUrl }` (mime from `core/clipboard.js` `mimeForName`; main has fs access, no CORS wall; 150 MB cap). The Cmd+V handler tries files → image bits → text in that order; web reads any file kind off the native `paste` event (not just `image/`). Both call `RemotePage.pasteFile(dataUrl, name, type)`, which synthesizes the same paste event but preserves the real name + MIME so upload targets accept a video. `pasteImage` is now a thin wrapper over `pasteFile`. **Drag-and-drop (t068)**: clipboard-as-file is flaky (a copied video may expose only an icon bitmap, not a `public.file-url`), so the robust path is dropping the file onto the screencast canvas — `viewport.tsx` reads the dropped `File`s via `FileReader` (identical on Electron + web, no clipboard ambiguity) and calls `RemotePage.dropFiles(specs, clientX, clientY)`, which maps the drop point through the Input-Forwarding coord resolver and synthesizes `dragenter`/`dragover`/`drop` DragEvents (carrying the files in a `DataTransfer`) on the remote element under the cursor. A window-level guard `preventDefault`s file drags outside an editable field / local webview so an errant drop can't navigate the Electron window to `file://`. **Chunked transfer (load-bearing):** both `pasteFile` and `dropFiles` are async and stream the file's base64 to the remote in ~2 MB chunks (`streamFiles` accumulates into `window.__cdpFiles[key]`; `assembleFilesExpr` joins + decodes into a real `File` then dispatches) — embedding a whole 62 MB video as one `Runtime.evaluate` source literal froze the renderer parsing it (and the screencast shares the CDP socket). Per-file size is capped at 100 MB on drop (toasted when skipped). Clipboard *paste* of a file stays OS-dependent (a copied video may expose only an icon bitmap, no `public.file-url`), so drag-drop is the reliable path. **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. - **Service-worker push capture (t067, Slack)**: Slack delivers many notifications from its service worker's `push` handler via `registration.showNotification` — a separate realm the page hook can't reach. The side-channel now also attaches to the matching `service_worker` target (a Slack adapter `swScript`) and injects `inject/slack-sw-notify.js` via `Runtime.evaluate` (a worker has no `Page` domain, so no document-start hook + no keep-alive), patching `ServiceWorkerRegistration.prototype.showNotification` to ship the same `__cdpNotify` toasts. The single Slack SW serves every workspace (origin-level), so the SW URL has no team id — the script derives the per-workspace `groupKey` from the notification payload (defensive probe, logged once for HITL tightening). **Known gap:** a worker that spins up fresh on a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier; a hardened version would use a browser-level `Target.setAutoAttach({waitForDebuggerOnStart:true})`). The t066 keep-alive keeps the registration warm enough to stay listed across reconciles in the common case. - **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. @@ -182,7 +182,7 @@ on pre-existing errors in untouched files). - Screencast frames are **CSS-resolution** (`Page.startScreencast` ignores `deviceScaleFactor`), so on a high-DPI display they're upscaled and look soft. Sharp device-resolution frames are only available via `Page.captureScreenshot`, which is too heavy to stream and color-shifts vs the screencast — see `docs/adr/0002-adaptive-viewport.md`. Not currently fixed. - Text input goes through `Input.dispatchKeyEvent`. macOS-reserved combos (Cmd+H hide, Cmd+M minimize, Cmd+Q quit, Ctrl+Cmd+F fullscreen, Cmd+` cycle windows) are detected by `isOsReservedKey` in `src/lib/key-routing.ts` and fall through to native macOS handlers rather than being forwarded. Common editing shortcuts (Cmd/Alt + arrows, line/word deletion) are translated to Blink editor commands, but full IME (CJK composition) is not supported. - **Finger touch is a lightweight mapping, not native touch.** A single finger on the screencast canvas drives the page through the existing mouse/wheel pipeline (ADR-0009): drag → `mouseWheel` scroll, tap → click, long-press → right-click. Gesture classification lives in the pure `src/lib/touch-gesture.ts`; `viewport.tsx` maps it onto `forwardInput` (so `toRemoteCoords` applies unchanged). Pinch-zoom, momentum/inertial scrolling, multi-touch, and full `Input.dispatchTouchEvent` are out (deferred to v0.2). Touch pointers are handled separately from the mouse path and `preventDefault`ed so iPad Safari's synthesized mouse events don't double-fire. -- No file download/upload support. +- No file download support, and no file-picker (``) upload. Files can be **dropped** onto the canvas or **pasted** (t068) — both inject the bytes as a base64 data URL over CDP, so large media (multi-hundred-MB video) is capped (100 MB on drop, toasted when skipped). A true file-picker upload would need `DOM.setFileInputFiles` plumbing. - Tab favicons may not load if the remote browser blocks cross-origin favicon requests. ## Troubleshooting diff --git a/src/app.tsx b/src/app.tsx index f78bbf8..a4233a3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1186,7 +1186,7 @@ export default function App() { window.cdp .readClipboardFiles() .then((files) => { - if (files && files.length) { + if (files?.length) { for (const f of files) page.pasteFile(f.dataUrl, f.name, f.type) return } diff --git a/src/components/viewport.tsx b/src/components/viewport.tsx index 82c3dbf..14c0335 100644 --- a/src/components/viewport.tsx +++ b/src/components/viewport.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react" +import { toast } from "sonner" import type { SwitchEffect } from "@/components/settings-dialog" import { useAnyPointerFine } from "@/hooks/use-pointer-coarse" import { type Event as AdaptiveEvent, type Bounds, initial, reduce } from "@/lib/adaptive-viewport" @@ -13,7 +14,7 @@ import { } from "@/lib/echo-cursor" import { isOsReservedKey } from "@/lib/key-routing" import { perfFrame, perfMark } from "@/lib/perf-mark" -import type { RemotePage } from "@/lib/remote-page" +import type { DropFileSpec, RemotePage } from "@/lib/remote-page" import { createTouchGesture, type GestureEvent, LONGPRESS_MS } from "@/lib/touch-gesture" import { drawFrame, type Size, toRemoteCoords } from "@/lib/viewport-transform" import { @@ -28,6 +29,34 @@ import { * the reflow as finished and reveal the tab. Adapts the freeze to connection speed and * page complexity (a heavy page like Outlook keeps emitting frames until it's done). */ const FRAMES_QUIET_MS = 200 + +/** Files dropped onto the canvas cross the CDP wire as a base64 data URL inside a + * Runtime.evaluate expression — fine for screenshots/clips, ruinous for a multi-GB movie. + * Cap each file and toast the ones skipped so a huge drop fails loudly, not silently. */ +const MAX_DROP_FILE_BYTES = 100 * 1024 * 1024 // 100 MB + +/** Reads a dropped FileList into data-URL specs, skipping files over the size cap. + * Returns the readable specs plus the names of any skipped (too-large) files. */ +async function readDroppedFiles( + files: File[], +): Promise<{ specs: DropFileSpec[]; skipped: string[] }> { + const specs: DropFileSpec[] = [] + const skipped: string[] = [] + for (const file of files) { + if (file.size > MAX_DROP_FILE_BYTES) { + skipped.push(file.name) + continue + } + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null) + reader.onerror = () => resolve(null) + reader.readAsDataURL(file) + }) + if (dataUrl) specs.push({ dataUrl, name: file.name, type: file.type }) + } + return { specs, skipped } +} /** Safety cap on the tab-switch freeze, in case frames never go quiet (animated page). */ const SETTLE_CAP_MS = 1500 /** Blur applied to the frozen frame during a tab-switch settle; eased back to 0 on @@ -94,6 +123,11 @@ export function Viewport({ }: ViewportProps) { const canvasRef = useRef(null) const containerRef = useRef(null) + // True while an OS file drag hovers the canvas — drives the drop-target highlight. + const [dragOver, setDragOver] = useState(false) + // Nested dragenter/dragleave fire per child; a depth counter keeps the highlight stable + // until the drag truly leaves the container (leave at depth 0). + const dragDepthRef = useRef(0) const imgRef = useRef(new Image()) // The single frame-view snapshot, captured the moment a frame is painted: the painted // frame's image px, plus its remote DIP geometry (device size + vertical offset) when @@ -653,8 +687,77 @@ export function Viewport({ } }, [page, maybeRearm]) + // An OS file dropped anywhere on an Electron window otherwise navigates it to file://. + // Swallow the default for file drags outside an editable field / local webview (those + // own their drops); the canvas's own onDrop still runs and forwards the files. + useEffect(() => { + const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files") + const isOwnTarget = (t: EventTarget | null) => { + const el = t as HTMLElement | null + if (!el?.tagName) return false + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "WEBVIEW" || + el.isContentEditable + ) + } + const guard = (e: DragEvent) => { + if (hasFiles(e) && !isOwnTarget(e.target)) e.preventDefault() + } + window.addEventListener("dragover", guard) + window.addEventListener("drop", guard) + return () => { + window.removeEventListener("dragover", guard) + window.removeEventListener("drop", guard) + } + }, []) + + const hasDragFiles = (e: React.DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files") + return ( -
+
{ + if (!hasDragFiles(e)) return + e.preventDefault() + dragDepthRef.current += 1 + setDragOver(true) + }} + onDragLeave={(e) => { + if (!hasDragFiles(e)) return + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragOver(false) + }} + onDragOver={(e) => { + if (!hasDragFiles(e)) return + e.preventDefault() + e.dataTransfer.dropEffect = "copy" + }} + onDrop={(e) => { + if (!e.dataTransfer?.files?.length) return + e.preventDefault() + dragDepthRef.current = 0 + setDragOver(false) + const { clientX, clientY } = e + const files = Array.from(e.dataTransfer.files) + void readDroppedFiles(files).then(({ specs, skipped }) => { + if (specs.length) { + const names = specs.map((s) => s.name).join(", ") + // The bytes stream to the remote in chunks (seconds for a big video); keep the + // toast up so the wait reads as progress, not a hang. + const tid = toast.loading(`Sending ${names} to the page…`) + page + .dropFiles(specs, clientX, clientY) + .then(() => toast.success(`Sent ${names} to the page`, { id: tid })) + .catch(() => toast.error(`Couldn't send ${names}`, { id: tid })) + } + if (skipped.length) toast(`Too large to drop (max 100 MB): ${skipped.join(", ")}`) + }) + }} + ref={containerRef} + > { @@ -747,6 +850,13 @@ export function Viewport({ ref={canvasRef} /> {showVirtualPointer && } + {dragOver && ( +
+
+ Drop files to send to the page +
+
+ )}
) } diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index eedbc11..2fbd645 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -4,7 +4,7 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire ## Modules -**`remote-page.ts`** — the Remote Page. `createRemotePage(transport)` wraps the CDP Transport seam into named intentions (`navigate`, `navigateSpa`, `openTeamsThread`, `back`, `forward`, `reload`, `selectAll`, `copySelection`, `getNavState`, `isLoading`, `paste`, `pasteImage`, `pasteFile`, `find`/`findStep`/`clearFind`) — `navigateSpa` drives client-side SPA routing (`pushState`+`popstate`, full-navigation fallback) for deep-opening, e.g. an Outlook message from a notification; `openTeamsThread` deep-opens a Teams conversation by clicking the chat row carrying the thread id (Teams has no URL route — see ADR-0003); `paste(text, {rich?})` inserts the local clipboard text into the remote focused element: `rich:false` (default) uses `Input.insertText` (plain, fires `input` events on React-controlled inputs); `rich:true` pre-seeds the remote clipboard via `Runtime.evaluate` and forwards Cmd+V so the page's `onpaste` handler runs; `pasteFile(dataUrl, name, type)` synthesizes a `paste` ClipboardEvent on the remote's focused element carrying the file as a `File` in a `DataTransfer` (rich editors / upload surfaces — Slack, Gmail, Drive — read `clipboardData.files`), preserving the real name + MIME so a video/doc is accepted (not just an image); `pasteImage(dataUrl)` is a thin wrapper over it for raw image bits; `find`/`findStep`/`clearFind` are the in-page find seam (t001) — they inject a per-document `window.__cdpFind` helper via `Runtime.evaluate` (`window.find` reports only a boolean, so the helper owns counting, stepping-with-wrap, scroll-into-view, and clearing) and report `{ total }` / `{ index }` via `returnByValue`; and the two subscription surfaces (`on` for typed events, `onFrame` for Screencast Frames). One registration on the raw transport; subscribers come and go — no re-registration, no leaks. Auto-acks every Screencast Frame before passing it to `onFrame` listeners. `forwardInput(InputIntent)` is the single Input Forwarding extension seam: new input kinds (IME, paste, drag) become new variants on `InputIntent` plus one `case` in `forwardInput`; no other interface changes. +**`remote-page.ts`** — the Remote Page. `createRemotePage(transport)` wraps the CDP Transport seam into named intentions (`navigate`, `navigateSpa`, `openTeamsThread`, `back`, `forward`, `reload`, `selectAll`, `copySelection`, `getNavState`, `isLoading`, `paste`, `pasteImage`, `pasteFile`, `dropFiles`, `find`/`findStep`/`clearFind`) — `navigateSpa` drives client-side SPA routing (`pushState`+`popstate`, full-navigation fallback) for deep-opening, e.g. an Outlook message from a notification; `openTeamsThread` deep-opens a Teams conversation by clicking the chat row carrying the thread id (Teams has no URL route — see ADR-0003); `paste(text, {rich?})` inserts the local clipboard text into the remote focused element: `rich:false` (default) uses `Input.insertText` (plain, fires `input` events on React-controlled inputs); `rich:true` pre-seeds the remote clipboard via `Runtime.evaluate` and forwards Cmd+V so the page's `onpaste` handler runs; `pasteFile(dataUrl, name, type)` synthesizes a `paste` ClipboardEvent on the remote's focused element carrying the file as a `File` in a `DataTransfer` (rich editors / upload surfaces — Slack, Gmail, Drive — read `clipboardData.files`), preserving the real name + MIME so a video/doc is accepted (not just an image); `pasteImage(dataUrl)` is a thin wrapper over it for raw image bits; `dropFiles(specs, clientX, clientY)` injects OS-dropped files by mapping the drop point through the coord resolver and synthesizing `dragenter`/`dragover`/`drop` DragEvents carrying the files in a `DataTransfer` on the remote element under the cursor (no-op when empty) — `viewport.tsx` reads the dropped `File`s via `FileReader`. Both `pasteFile` and `dropFiles` are **async** and **stream the payload in ~2 MB base64 chunks** (`streamFiles` → `window.__cdpFiles[key]`, then `assembleFilesExpr` joins + decodes into a real `File`); a whole 62 MB video as one `Runtime.evaluate` source literal froze the renderer parsing it (and the screencast shares the CDP socket), so chunking is load-bearing, not an optimization; `find`/`findStep`/`clearFind` are the in-page find seam (t001) — they inject a per-document `window.__cdpFind` helper via `Runtime.evaluate` (`window.find` reports only a boolean, so the helper owns counting, stepping-with-wrap, scroll-into-view, and clearing) and report `{ total }` / `{ index }` via `returnByValue`; and the two subscription surfaces (`on` for typed events, `onFrame` for Screencast Frames). One registration on the raw transport; subscribers come and go — no re-registration, no leaks. Auto-acks every Screencast Frame before passing it to `onFrame` listeners. `forwardInput(InputIntent)` is the single Input Forwarding extension seam: new input kinds (IME, paste, drag) become new variants on `InputIntent` plus one `case` in `forwardInput`; no other interface changes. **`tabs.ts`** — Tab ordering and lifecycle. `reconcile(order, remoteTabs)` merges the Remote Browser's tab list against the locally-owned order: existing tabs keep position, gone tabs drop out, new tabs append. `nextTab`/`prevTab` wrap around. `stripTitleBadge(title)` strips a leading `(N)` unread count that some apps (e.g. Teams) prepend to the document title — the app surfaces unread counts via its own tab badge, so the title shouldn't duplicate it. diff --git a/src/lib/remote-page.test.ts b/src/lib/remote-page.test.ts index fb1c428..814ebb7 100644 --- a/src/lib/remote-page.test.ts +++ b/src/lib/remote-page.test.ts @@ -354,32 +354,63 @@ describe("RemotePage clipboard paste", () => { expect(t.sends[0].params).toMatchObject({ type: "keyDown", key: "v", commandKey: true }) }) - it("pasteImage evaluates a synthetic paste event injecting the data URL", () => { + it("pasteImage streams the payload in chunks then dispatches a paste event", async () => { const t = fakeTransport() const page = createRemotePage(t.transport) - page.pasteImage("data:image/png;base64,ABC") + await page.pasteImage("data:image/png;base64,ABC") - expect(t.invoke).toHaveBeenCalledWith( - "Runtime.evaluate", - expect.objectContaining({ awaitPromise: true }), + const exprs = t.invoke.mock.calls.map((c) => c[1].expression as string) + const dispatch = exprs[exprs.length - 1] + expect(dispatch).toContain('ClipboardEvent("paste"') + expect(dispatch).toContain("pasted-image.png") + // The base64 payload rides the chunk pushes, never the final dispatch (no giant literal). + expect(exprs.some((e) => e.includes("ABC"))).toBe(true) + expect(dispatch).not.toContain("ABC") + }) + + it("pasteFile carries the file name and mime type into the synthesized File", async () => { + const t = fakeTransport() + const page = createRemotePage(t.transport) + + await page.pasteFile("data:video/mp4;base64,XYZ", "clip.mp4", "video/mp4") + + const exprs = t.invoke.mock.calls.map((c) => c[1].expression as string) + const dispatch = exprs[exprs.length - 1] + expect(dispatch).toContain('ClipboardEvent("paste"') + expect(dispatch).toContain("clip.mp4") + expect(dispatch).toContain("video/mp4") + expect(exprs.some((e) => e.includes("XYZ"))).toBe(true) + expect(dispatch).not.toContain("XYZ") + }) + + it("dropFiles streams chunks then dispatches a drop at the resolved remote coords", async () => { + const t = fakeTransport() + const page = createRemotePage(t.transport) + page.setCoordResolver(() => ({ x: 100, y: 200 })) + + await page.dropFiles( + [{ dataUrl: "data:video/mp4;base64,XYZ", name: "clip.mp4", type: "video/mp4" }], + 10, + 20, ) - const expr = t.invoke.mock.calls[0][1].expression as string - expect(expr).toContain('ClipboardEvent("paste"') - expect(expr).toContain("data:image/png;base64,ABC") + + const exprs = t.invoke.mock.calls.map((c) => c[1].expression as string) + const dispatch = exprs[exprs.length - 1] + expect(dispatch).toContain('DragEvent("drop"') + expect(dispatch).toContain("elementFromPoint(100, 200)") + expect(dispatch).toContain("clip.mp4") + expect(exprs.some((e) => e.includes("XYZ"))).toBe(true) + expect(dispatch).not.toContain("XYZ") }) - it("pasteFile carries the file name and mime type into the synthesized File", () => { + it("dropFiles is a no-op when no files are given", async () => { const t = fakeTransport() const page = createRemotePage(t.transport) - page.pasteFile("data:video/mp4;base64,XYZ", "clip.mp4", "video/mp4") + await page.dropFiles([], 10, 20) - const expr = t.invoke.mock.calls[0][1].expression as string - expect(expr).toContain('ClipboardEvent("paste"') - expect(expr).toContain("data:video/mp4;base64,XYZ") - expect(expr).toContain("clip.mp4") - expect(expr).toContain("video/mp4") + expect(t.invoke).not.toHaveBeenCalled() }) }) diff --git a/src/lib/remote-page.ts b/src/lib/remote-page.ts index d0f7b72..3c905fe 100644 --- a/src/lib/remote-page.ts +++ b/src/lib/remote-page.ts @@ -94,6 +94,13 @@ export type InputIntent = } | { kind: "wheel"; event: WheelEventLike } +/** A file to inject into the remote page (paste or drop), as a base64 data URL. */ +export interface DropFileSpec { + dataUrl: string + name: string + type: string +} + export interface RemotePageOptions { /** Maps a client point to Remote Page pixels (the injected Viewport Transform). */ resolveCoords?: (clientX: number, clientY: number) => { x: number; y: number } @@ -242,15 +249,25 @@ export interface RemotePage { * ClipboardEvent carrying the image as a File (rich editors read clipboardData.files). * `dataUrl` is a `data:image/...;base64,…` string. */ - pasteImage(dataUrl: string): void + pasteImage(dataUrl: string): Promise /** * Pastes an arbitrary file (video, audio, doc, image) into the remote page's * focused element by synthesizing a `paste` ClipboardEvent carrying the file as a * `File` in a `DataTransfer`. Unlike `pasteImage` this preserves the original file * name + MIME type so upload targets that sniff extension/type (Slack, Drive) accept - * it. `dataUrl` is a `data:;base64,…` string. + * it. `dataUrl` is a `data:;base64,…` string. The payload is streamed to the + * remote in chunks (see `streamFiles`) so a large file can't freeze the CDP link. + */ + pasteFile(dataUrl: string, name: string, type: string): Promise + /** + * Drops one or more files onto the remote page at the given client coordinates by + * synthesizing dragenter/dragover/drop DragEvents on the element under the cursor, + * each carrying the files in a `DataTransfer` (upload dropzones — Slack, Gmail, Drive — + * read `dataTransfer.files`). `clientX`/`clientY` are canvas-relative client px, mapped + * to Remote Page DIP via the injected coord resolver (the same path as Input Forwarding). + * Payload is streamed in chunks so a large video can't freeze the link. No-op when empty. */ - pasteFile(dataUrl: string, name: string, type: string): void + dropFiles(files: DropFileSpec[], clientX: number, clientY: number): Promise /** * In-page find (t001). The remote-side search is an injected per-document routine * (`window.find` reports only a boolean — it can't count or step deterministically), @@ -273,6 +290,62 @@ function normalizeUrl(url: string): string { return /^https?:\/\//.test(url) ? url : `https://${url}` } +/** + * ~2 MB of base64 chars per Runtime.evaluate. Small enough that the remote parses each + * chunk's source literal in a single fast tick (a whole-file literal — tens of MB — would + * block the renderer parsing it, freezing the screencast that shares the CDP socket), big + * enough to keep the round-trip count low. See `streamFiles`. + */ +const FILE_CHUNK_CHARS = 2_000_000 + +/** + * Streams each file's base64 payload into the remote page in bounded chunks, accumulating + * them under `window.__cdpFiles[key]`. Returns the per-file keys (in input order) so the + * caller's assemble step can join + decode them. Sequential by design — order matters for + * the join, and interleaving small evaluates with screencast frames is what keeps the link + * from stalling. The bytes never travel inside an evaluate's source as one giant literal. + */ +async function streamFiles( + transport: Transport, + files: DropFileSpec[], + seq: number, +): Promise { + const keys: string[] = [] + for (let fi = 0; fi < files.length; fi++) { + const key = `s${seq}_${fi}` + keys.push(key) + const b64 = files[fi].dataUrl.slice(files[fi].dataUrl.indexOf(",") + 1) + await transport.invoke("Runtime.evaluate", { + expression: `((window.__cdpFiles||(window.__cdpFiles={}))[${JSON.stringify(key)}]=[])`, + }) + for (let i = 0; i < b64.length; i += FILE_CHUNK_CHARS) { + await transport.invoke("Runtime.evaluate", { + expression: `window.__cdpFiles[${JSON.stringify(key)}].push(${JSON.stringify( + b64.slice(i, i + FILE_CHUNK_CHARS), + )})`, + }) + } + } + return keys +} + +/** + * In-page JS that reconstructs a `DataTransfer` (`dt`) from the streamed chunks: joins each + * file's base64, decodes to bytes, and builds a real `File` (name + MIME preserved) the + * remote page reads from `clipboardData.files` / `dataTransfer.files`. Frees `__cdpFiles` + * after. Returns a statement block defining `dt`; pairs with `streamFiles`. + */ +function assembleFilesExpr(keys: string[], files: DropFileSpec[]): string { + const metas = keys.map((key, i) => ({ key, name: files[i].name, type: files[i].type })) + return `const dt = new DataTransfer(); + for (const m of ${JSON.stringify(metas)}) { + const b64 = ((window.__cdpFiles||{})[m.key]||[]).join(""); + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + dt.items.add(new File([bytes], m.name, { type: m.type || "application/octet-stream" })); + try { delete window.__cdpFiles[m.key]; } catch (e) {} + }` +} + export function createRemotePage( transport: Transport, options: RemotePageOptions = {}, @@ -291,6 +364,8 @@ export function createRemotePage( // parentId); seeded from the first loading event when still unknown, and reset on // disconnect so each tab tracks its own frame. let mainFrameId: string | undefined + // Monotonic id so concurrent file injections never clobber each other's `__cdpFiles` keys. + let fileSeq = 0 // One registration on the raw transport, demuxed to typed subscribers. Subscribers // come and go via `on`'s unsubscribe — the transport listener is registered once. @@ -489,25 +564,42 @@ export function createRemotePage( } }, pasteImage(dataUrl) { - this.pasteFile(dataUrl, "pasted-image.png", "image/png") + return this.pasteFile(dataUrl, "pasted-image.png", "image/png") }, - pasteFile(dataUrl, name, type) { + async pasteFile(dataUrl, name, type) { // Input.insertText can't carry binary, so synthesize a paste event on the remote's // focused element with a DataTransfer holding the File — rich editors / upload // surfaces (Slack, Gmail, Drive) that listen for `paste` read it from // clipboardData.files. Name + type are preserved so the target accepts the file - // (a video needs its real extension/MIME, not a generic image). - transport.invoke("Runtime.evaluate", { - expression: `(async () => { - const res = await fetch(${JSON.stringify(dataUrl)}); - const blob = await res.blob(); - const file = new File([blob], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} || blob.type || "application/octet-stream" }); - const dt = new DataTransfer(); - dt.items.add(file); + // (a video needs its real extension/MIME, not a generic image). Streamed in chunks + // so a large file never lands as one giant Runtime.evaluate literal (that freezes CDP). + const files = [{ dataUrl, name, type }] + const keys = await streamFiles(transport, files, fileSeq++) + await transport.invoke("Runtime.evaluate", { + expression: `(() => { + ${assembleFilesExpr(keys, files)} const el = document.activeElement || document.body; el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); })()`, - awaitPromise: true, + }) + }, + async dropFiles(files, clientX, clientY) { + if (!files.length) return + // Map the drop point to Remote Page DIP (same resolver as Input Forwarding), then + // synthesize dragenter/dragover/drop on the element under the cursor. Many upload + // dropzones only listen for `drop` + `dataTransfer.files`; the lead-in drag events + // satisfy the ones that gate on a prior dragover. Streamed in chunks (see streamFiles). + const { x, y } = resolveCoords(clientX, clientY) + const keys = await streamFiles(transport, files, fileSeq++) + await transport.invoke("Runtime.evaluate", { + expression: `(() => { + ${assembleFilesExpr(keys, files)} + const el = document.elementFromPoint(${x}, ${y}) || document.body; + const base = { bubbles: true, cancelable: true, composed: true, clientX: ${x}, clientY: ${y}, dataTransfer: dt }; + el.dispatchEvent(new DragEvent("dragenter", base)); + el.dispatchEvent(new DragEvent("dragover", base)); + el.dispatchEvent(new DragEvent("drop", base)); + })()`, }) }, async find(query) {