From ef548b4ba7a52b5e49b736c99c52c93b533122fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 01:51:36 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(macos+web):=20vellum://=20deep=20links?= =?UTF-8?q?=20=E2=86=92=20event=20bus,=20with=20pre-mount=20buffering=20(L?= =?UTF-8?q?UM-1872)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Capability `vellum://send?message=…` and `vellum://thread/` URLs routed by Launch Services from any app (Mail, Slack, browser address bar, `open vellum://...` shell) reach the running Electron app, parse into typed `DeepLink`, and broadcast to the renderer where they fan into typed bus events. Chat domain (separate PR) subscribes to `deeplink.send` / `deeplink.openThread` to act. ## Shape — second use of the ELECTRON.md push-signals pattern, extended with pre-renderer-ready buffering Same 5-step shape as the power-events bridge (LUM-1974) — the convention pays off on its first reuse. One addition: deep links can arrive BEFORE the renderer exists (OS launches the app via a URL click → `open-url` fires before `whenReady`). Captured in a new bridge surface pair documented in ELECTRON.md. 1. `apps/macos/src/main/deep-links.ts` — `installDeepLinks()`: - `app.setAsDefaultProtocolClient` for `vellum` + `vellum-assistant` - `app.on("will-finish-launching")` → registers `open-url` (the #1 deep-link bug: registering in `whenReady` misses the launching URL) - `parseVellumUrl(url)` pure parser → typed `DeepLink` - `extractDeepLinkFromArgv(argv)` for Windows/Linux second-instance forwarding - Buffer + broadcast: pending links go into module-scope `pending[]`, also broadcast via `webContents.send` to every BrowserWindow - `ipcMain.handle("vellum:deepLinks:drain")` returns + clears the buffer - Foreign-scheme rejection (`javascript:`, `data:`, `file:`, `http:`) → `kind: "unknown"` so the bridge surface stays exhaustive 2. `apps/macos/src/main/index.ts` — calls `installDeepLinks()` at top-level (before `whenReady`) so the will-finish-launching subscription captures launching URLs. Extends the existing `second-instance` handler to also extract URLs from argv via `extractDeepLinkFromArgv`, covering Windows/Linux where `open-url` never fires. 3. `apps/macos/src/preload/index.ts` — `window.vellum.deepLinks.drain() → Promise` + `onLink(callback) → unsubscribe`. `DeepLink` type mirrored inline per the existing convention. 4. `apps/web/src/runtime/is-electron.ts` — ambient declaration. 5. `apps/web/src/runtime/deep-links.ts` — `subscribeToDeepLinks(cb)` + `drainPendingDeepLinks()`. Both no-op off Electron. 6. `apps/web/src/stores/event-bus-store.ts` — `BusEventMap` grows `deeplink.send { message }`, `deeplink.openThread { threadId }`, `deeplink.unknown { url }`. 7. `apps/web/src/hooks/use-event-bus-init.ts` — subscribe-then-drain order matters: a link landing between drain completion and subscription would be lost otherwise. Subscribe first, drain second. Each drained or live link is narrowed into the appropriate typed bus event. 8. `apps/web/docs/EVENT_BUS.md` + `apps/web/docs/ELECTRON.md` — table grows the three `deeplink.*` events; ELECTRON.md gains a sub-section on "when signals can arrive before the renderer exists" documenting the subscribe-then-drain pattern. ## Tests - `apps/macos/src/main/deep-links.test.ts` (22 cases): parser matrix (send happy/empty/decoded, openThread happy/multi-segment/ missing-id, foreign schemes javascript/file/http, malformed URLs, unrecognized hosts); argv extraction (matches scheme, alternate scheme, returns null); install (both schemes registered + idempotent, will-finish-launching → open-url chain, open-url preventDefault + buffer, drain IPC clears buffer); broadcast (all windows, skips destroyed, unknown-kind still broadcast). - `apps/web/src/hooks/use-event-bus-init.test.tsx` (+5 cases): live `deeplink.send` / `deeplink.openThread` / `deeplink.unknown` publish to typed bus events; drained pending links publish in order; subscribe-BEFORE-drain ordering verified via `invocationCallOrder`; unsubscribe on unmount stops live delivery. 53 renderer bus tests + 9 macOS test files green. ## Out of scope (follow-up PRs) - **Renderer-side actions**: composer pre-fill (`deeplink.send`) and thread navigation (`deeplink.openThread`) — owned by the chat domain, separate ticket. - **electron-builder `protocols` Info.plist block** — the build pipeline doesn't ship a .app yet (LUM-2024 territory). Dynamic `setAsDefaultProtocolClient` works for dev today; the static registration matters only for distribution. - **Bringing window forward + accessory-mode transition** on deep link: the bridge currently broadcasts but doesn't focus the window. The chat domain's deeplink consumer should call `ensureMainWindowVisible` (via a new bridge method or by triggering an existing one) when it acts. Captured here so the signal-source PR isn't overloaded with consumer concerns. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe --- apps/macos/src/main/deep-links.test.ts | 261 ++++++++++++++++++ apps/macos/src/main/deep-links.ts | 164 +++++++++++ apps/macos/src/main/index.ts | 21 +- apps/macos/src/preload/index.ts | 44 +++ apps/web/docs/ELECTRON.md | 9 + apps/web/docs/EVENT_BUS.md | 3 + .../web/src/hooks/use-event-bus-init.test.tsx | 139 ++++++++++ apps/web/src/hooks/use-event-bus-init.ts | 50 +++- apps/web/src/runtime/deep-links.ts | 37 +++ apps/web/src/runtime/is-electron.ts | 17 ++ apps/web/src/stores/event-bus-store.ts | 17 ++ 11 files changed, 754 insertions(+), 8 deletions(-) create mode 100644 apps/macos/src/main/deep-links.test.ts create mode 100644 apps/macos/src/main/deep-links.ts create mode 100644 apps/web/src/runtime/deep-links.ts diff --git a/apps/macos/src/main/deep-links.test.ts b/apps/macos/src/main/deep-links.test.ts new file mode 100644 index 00000000000..4c07ca7637a --- /dev/null +++ b/apps/macos/src/main/deep-links.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +// Capture the `will-finish-launching` and `open-url` subscriptions +// from `app.on` so tests can fire them. `setAsDefaultProtocolClient` +// is also captured to verify scheme registration. +type Listener = (...args: unknown[]) => void; +const appListeners = new Map(); +const appOnMock = mock((event: string, listener: Listener) => { + appListeners.set(event, listener); +}); +const setAsDefaultProtocolClientMock = mock((_scheme: string) => true); +const ipcHandleMock = mock( + (_channel: string, _handler: (...args: unknown[]) => unknown) => undefined, +); +let windows: Array<{ + isDestroyed: () => boolean; + webContents: { send: ReturnType }; +}> = []; + +mock.module("electron", () => ({ + app: { + on: appOnMock, + setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, + }, + ipcMain: { handle: ipcHandleMock }, + BrowserWindow: { getAllWindows: () => windows }, +})); + +const { + __resetForTesting, + extractDeepLinkFromArgv, + handleDeepLink, + installDeepLinks, + parseVellumUrl, +} = await import("./deep-links"); + +const makeWindow = (destroyed = false) => ({ + isDestroyed: () => destroyed, + webContents: { send: mock(() => undefined) }, +}); + +beforeEach(() => { + __resetForTesting(); + appListeners.clear(); + appOnMock.mockClear(); + setAsDefaultProtocolClientMock.mockClear(); + ipcHandleMock.mockClear(); + windows = []; +}); + +afterEach(() => { + windows = []; +}); + +describe("parseVellumUrl", () => { + test("vellum://send?message=hi → send with the message", () => { + expect(parseVellumUrl("vellum://send?message=hi")).toEqual({ + kind: "send", + message: "hi", + }); + }); + + test("vellum-assistant://send?message=hi → same shape under the alternate scheme", () => { + expect(parseVellumUrl("vellum-assistant://send?message=hi")).toEqual({ + kind: "send", + message: "hi", + }); + }); + + test("vellum://send → empty message (preserved, renderer decides)", () => { + expect(parseVellumUrl("vellum://send")).toEqual({ + kind: "send", + message: "", + }); + }); + + test("vellum://send decodes percent-encoded query parameters", () => { + expect(parseVellumUrl("vellum://send?message=hello%20world")).toEqual({ + kind: "send", + message: "hello world", + }); + }); + + test("vellum://thread/abc-123 → openThread with the id", () => { + expect(parseVellumUrl("vellum://thread/abc-123")).toEqual({ + kind: "openThread", + threadId: "abc-123", + }); + }); + + test("vellum://thread/abc-123/extra → openThread on first segment, extras ignored", () => { + expect(parseVellumUrl("vellum://thread/abc-123/extra")).toEqual({ + kind: "openThread", + threadId: "abc-123", + }); + }); + + test("vellum://thread → unknown (no id)", () => { + expect(parseVellumUrl("vellum://thread")).toEqual({ + kind: "unknown", + url: "vellum://thread", + }); + }); + + test("rejects foreign schemes — javascript: returns unknown", () => { + expect(parseVellumUrl("javascript:alert(1)")).toEqual({ + kind: "unknown", + url: "javascript:alert(1)", + }); + }); + + test("rejects file: scheme", () => { + expect(parseVellumUrl("file:///etc/passwd")).toEqual({ + kind: "unknown", + url: "file:///etc/passwd", + }); + }); + + test("rejects http: scheme", () => { + expect(parseVellumUrl("http://vellum.ai/send")).toEqual({ + kind: "unknown", + url: "http://vellum.ai/send", + }); + }); + + test("malformed input → unknown (catches URL constructor throws)", () => { + expect(parseVellumUrl("not a url at all")).toEqual({ + kind: "unknown", + url: "not a url at all", + }); + }); + + test("unrecognized vellum://… host → unknown", () => { + expect(parseVellumUrl("vellum://garbage")).toEqual({ + kind: "unknown", + url: "vellum://garbage", + }); + }); +}); + +describe("extractDeepLinkFromArgv", () => { + test("returns the first vellum:// URL in argv", () => { + const argv = [ + "/usr/local/bin/electron", + "--inspect=9229", + "vellum://send?message=hi", + "--unrelated", + ]; + expect(extractDeepLinkFromArgv(argv)).toBe("vellum://send?message=hi"); + }); + + test("matches the alternate scheme too", () => { + expect(extractDeepLinkFromArgv(["vellum-assistant://thread/x"])).toBe( + "vellum-assistant://thread/x", + ); + }); + + test("returns null when no deep-link arg is present", () => { + expect(extractDeepLinkFromArgv(["/usr/local/bin/electron", "--foo"])) + .toBeNull(); + }); +}); + +describe("installDeepLinks", () => { + test("registers both schemes with Launch Services and is idempotent across repeated calls", () => { + installDeepLinks(); + installDeepLinks(); + installDeepLinks(); + + const schemes = setAsDefaultProtocolClientMock.mock.calls.map((c) => c[0]); + expect(schemes).toContain("vellum"); + expect(schemes).toContain("vellum-assistant"); + // 2 schemes × 1 install = 2 calls total (idempotent). + expect(setAsDefaultProtocolClientMock).toHaveBeenCalledTimes(2); + }); + + test("subscribes to will-finish-launching and registers an open-url listener under it", () => { + installDeepLinks(); + const wfl = appListeners.get("will-finish-launching"); + expect(wfl).toBeDefined(); + + wfl?.(); + expect(appListeners.has("open-url")).toBe(true); + }); + + test("open-url calls preventDefault on the event and buffers the parsed link", () => { + installDeepLinks(); + appListeners.get("will-finish-launching")?.(); + const openUrl = appListeners.get("open-url"); + expect(openUrl).toBeDefined(); + + const preventDefault = mock(() => undefined); + openUrl?.({ preventDefault } as unknown, "vellum://send?message=hi"); + + expect(preventDefault).toHaveBeenCalled(); + }); + + test("registers the vellum:deepLinks:drain IPC handler returning + clearing the buffer", () => { + installDeepLinks(); + + handleDeepLink("vellum://send?message=one"); + handleDeepLink("vellum://thread/abc"); + + // Find the registered handler. + const drainCall = ipcHandleMock.mock.calls.find( + (c) => c[0] === "vellum:deepLinks:drain", + ); + expect(drainCall).toBeDefined(); + const drainHandler = drainCall![1] as () => unknown; + + expect(drainHandler()).toEqual([ + { kind: "send", message: "one" }, + { kind: "openThread", threadId: "abc" }, + ]); + // Second drain returns empty — buffer was cleared. + expect(drainHandler()).toEqual([]); + }); +}); + +describe("handleDeepLink — broadcast", () => { + test("broadcasts to every BrowserWindow's webContents", () => { + const w1 = makeWindow(); + const w2 = makeWindow(); + windows = [w1, w2]; + + handleDeepLink("vellum://send?message=broadcast"); + + const expected = { kind: "send", message: "broadcast" }; + expect(w1.webContents.send).toHaveBeenCalledWith( + "vellum:deepLinks:event", + expected, + ); + expect(w2.webContents.send).toHaveBeenCalledWith( + "vellum:deepLinks:event", + expected, + ); + }); + + test("skips destroyed windows", () => { + const alive = makeWindow(); + const dead = makeWindow(true); + windows = [alive, dead]; + + handleDeepLink("vellum://send?message=skip"); + + expect(alive.webContents.send).toHaveBeenCalled(); + expect(dead.webContents.send).not.toHaveBeenCalled(); + }); + + test("unknown-kind links are still broadcast (renderer logs / drops)", () => { + const w = makeWindow(); + windows = [w]; + + handleDeepLink("javascript:alert(1)"); + + expect(w.webContents.send).toHaveBeenCalledWith("vellum:deepLinks:event", { + kind: "unknown", + url: "javascript:alert(1)", + }); + }); +}); diff --git a/apps/macos/src/main/deep-links.ts b/apps/macos/src/main/deep-links.ts new file mode 100644 index 00000000000..3250cb5bf51 --- /dev/null +++ b/apps/macos/src/main/deep-links.ts @@ -0,0 +1,164 @@ +import { BrowserWindow, app, ipcMain } from "electron"; + +/** + * Inbound deep links — `vellum://` and `vellum-assistant://` URL + * schemes. The OS routes any user click on a `vellum://send?message=hi` + * link (Mail, Slack, browser address bar, `open vellum://...` shell + * command) to the running Electron app, where we parse it into a + * typed `DeepLink` and broadcast to the renderer. + * + * Why this exists as a separate module (rather than reusing the + * application-menu command bus): deep links have parsing, buffering, + * and pre-`whenReady` arrival semantics that menu commands don't. + * The convention in `ELECTRON.md` for cross-domain push signals + * applies — the renderer's bus is the consumer surface; this module + * is the signal source. + * + * Lifecycle hooks (all required): + * + * - `app.setAsDefaultProtocolClient(scheme)` registers the schemes + * with Launch Services dynamically. Required for dev builds (the + * dev `.app` bundle isn't installed normally); also defensive + * registration for packaged builds. + * - `app.on("will-finish-launching", () => app.on("open-url", ...))` + * captures URLs delivered AT launch (the OS opens the app via a + * link click → `open-url` fires before `ready`). Registering in + * `whenReady` misses the launching URL — the #1 deep-link bug. + * - `app.on("second-instance")` forwards URLs from a second-launch + * attempt. macOS delivers the URL via a fresh `open-url` on the + * primary instance (argv is empty); Windows / Linux deliver via + * argv only (`open-url` never fires). We handle both. + * + * Buffering: deep links arriving before the renderer is ready (or + * before the first `vellum:deepLinks:drain` IPC call) are queued in + * a module-scope `pending[]`. The renderer drains via + * `window.vellum.deepLinks.drain()` once mounted. Live links arriving + * after drain are pushed to the buffer too AND broadcast to every + * BrowserWindow's `vellum:deepLinks:event` channel; consumers + * subscribe-then-drain to avoid races. + * + * Reference: + * - https://www.electronjs.org/docs/latest/api/app#event-will-finish-launching + * - https://www.electronjs.org/docs/latest/api/app#appsetasdefaultprotocolclientprotocol-path-args + */ + +export type DeepLink = + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string }; + +const ACCEPTED_SCHEMES = ["vellum:", "vellum-assistant:"] as const; + +/** + * Pure parser: URL string → typed `DeepLink`. Exported for unit tests. + * + * Rules: + * + * - Scheme MUST be `vellum:` or `vellum-assistant:`. Anything else + * (`javascript:`, `data:`, `file:`, foreign customs) is rejected + * as `kind: "unknown"` — the renderer additionally defensively + * validates before dispatching. + * - `vellum://send?message=…` → `{ kind: "send", message }`. Empty + * `message` is preserved (renderer can decide to open an empty + * composer). + * - `vellum://thread/` → `{ kind: "openThread", threadId }`. + * Trailing slashes / extra path segments are tolerated; + * `threadId` is the first non-empty path segment. + * - Malformed URL (unparseable, percent-encoding throws) → + * `kind: "unknown"`. + */ +export const parseVellumUrl = (input: string): DeepLink => { + let url: URL; + try { + url = new URL(input); + } catch { + return { kind: "unknown", url: input }; + } + if (!ACCEPTED_SCHEMES.includes(url.protocol as (typeof ACCEPTED_SCHEMES)[number])) { + return { kind: "unknown", url: input }; + } + if (url.host === "send") { + return { kind: "send", message: url.searchParams.get("message") ?? "" }; + } + if (url.host === "thread") { + const threadId = url.pathname.replace(/^\/+/, "").split("/")[0] ?? ""; + if (threadId) return { kind: "openThread", threadId }; + return { kind: "unknown", url: input }; + } + return { kind: "unknown", url: input }; +}; + +/** + * Find the first deep-link URL in an argv. Used on Windows / Linux + * where the OS delivers second-instance deep links via argv rather + * than via `app.on("open-url")` like macOS. Exported for unit tests. + */ +export const extractDeepLinkFromArgv = (argv: readonly string[]): string | null => { + for (const arg of argv) { + if (ACCEPTED_SCHEMES.some((scheme) => arg.startsWith(scheme))) return arg; + } + return null; +}; + +const pending: DeepLink[] = []; + +const broadcast = (link: DeepLink): void => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + win.webContents.send("vellum:deepLinks:event", link); + } +}; + +/** + * Main entry — parse, buffer, broadcast. Internal to this module; + * exposed via the `open-url` / `second-instance` event handlers and + * exported for tests. + */ +export const handleDeepLink = (input: string): void => { + const link = parseVellumUrl(input); + pending.push(link); + broadcast(link); +}; + +let installed = false; + +/** + * Wire the deep-link handlers. Called at module-top-level (NOT from + * `whenReady`) so the `will-finish-launching` subscription captures + * URLs delivered AT launch. + */ +export const installDeepLinks = (): void => { + if (installed) return; + installed = true; + + // Dynamic registration. Packaged builds also declare these in + // `electron-builder.yml`'s `protocols` (so `Info.plist` carries + // `CFBundleURLTypes`); the dynamic call is required for dev and + // defensive for prod. + for (const scheme of ACCEPTED_SCHEMES) { + app.setAsDefaultProtocolClient(scheme.replace(/:$/, "")); + } + + app.on("will-finish-launching", () => { + app.on("open-url", (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); + }); + + // Renderer drains on mount. Returns AND clears the buffer — live + // links arriving after drain are delivered via the broadcast + // channel above; the renderer's `runtime/deep-links.ts` wrapper + // does subscribe-then-drain to avoid losing in-flight events. + ipcMain.handle("vellum:deepLinks:drain", (): DeepLink[] => { + const drained = pending.splice(0, pending.length); + return drained; + }); +}; + +// Test seam — exported only for unit-test setup. Production code +// uses `installDeepLinks` instead. +export const __resetForTesting = (): void => { + installed = false; + pending.length = 0; +}; diff --git a/apps/macos/src/main/index.ts b/apps/macos/src/main/index.ts index 7c246a905d7..f0122bbc825 100644 --- a/apps/macos/src/main/index.ts +++ b/apps/macos/src/main/index.ts @@ -7,6 +7,11 @@ import path from "node:path"; import { installAbout, openAboutWindow } from "./about"; import { APP_PROTOCOL } from "./app-config"; import { resolveAppProtocolPath } from "./app-protocol"; +import { + extractDeepLinkFromArgv, + handleDeepLink, + installDeepLinks, +} from "./deep-links"; import { installDock } from "./dock"; import { ensureVisible as ensureMainWindowVisible, @@ -69,6 +74,13 @@ protocol.registerSchemesAsPrivileged([ }, ]); +// Deep-link plumbing — register at module top-level so the +// `will-finish-launching` subscription captures URLs delivered AT +// launch (the OS opens the app via a `vellum://` click → `open-url` +// can fire before `whenReady`). Registering in `whenReady` misses +// the launching URL — the #1 deep-link bug in Electron apps. +installDeepLinks(); + // Serve apps/web/dist/ as static files via `app://vellum.ai/...`. Route-like // paths (no file extension, or `.html`) fall back to index.html so React // Router can handle client-side routes on reload / deep-link; requests for @@ -273,12 +285,19 @@ app console.error("[app] whenReady setup failed:", err); }); -app.on("second-instance", () => { +app.on("second-instance", (_event, argv) => { // Behavior change vs prior code path: previously a second-instance // launch was a no-op when the main window had been destroyed. Now // we recreate so the user always sees a window in response to // re-launching the app. ensureMainWindowVisible(); + // Cross-platform deep-link delivery: macOS routes second-launch + // deep links via a fresh `open-url` on the primary instance (argv + // is empty). Windows / Linux deliver the URL via argv and + // `open-url` never fires. Always check argv here so the buffered + // / broadcast pipeline is platform-agnostic. + const deepLink = extractDeepLinkFromArgv(argv); + if (deepLink) handleDeepLink(deepLink); }); app.on("web-contents-created", (_event, contents) => { diff --git a/apps/macos/src/preload/index.ts b/apps/macos/src/preload/index.ts index 503e9f8d484..33279ba85b7 100644 --- a/apps/macos/src/preload/index.ts +++ b/apps/macos/src/preload/index.ts @@ -32,6 +32,19 @@ export interface AppVersionInfo { website: string; } +/** + * Mirror of `DeepLink` in `apps/macos/src/main/deep-links.ts`. Inlined + * (same convention as the other bridge types) — preload + main + + * renderer each have their own TS project; cheaper to maintain a tiny + * literal union three places than to wire cross-project imports. Drift + * surfaces as a renderer handler not narrowing on a new kind, which + * is a graceful no-op rather than a crash. + */ +export type DeepLink = + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string }; + /** * Mirror of `PowerEventKind` in `apps/macos/src/main/power-events.ts`. * Inlined for the same reason as `VellumCommand` / `AppVersionInfo`: @@ -121,6 +134,24 @@ export interface VellumBridge { */ onEvent(callback: (event: PowerEvent) => void): () => void; }; + deepLinks: { + /** + * Drain and return the buffer of deep links that arrived before + * the renderer was ready. Returns ALL pending links and clears + * the buffer. The renderer wrapper does subscribe-then-drain to + * avoid losing a live link that arrives between `onLink` + * subscription and `drain` completion. + */ + drain(): Promise; + /** + * Subscribe to live deep links (links arriving after the + * renderer is up). Returns an unsubscribe function; callers + * invoke it on cleanup. Links arriving before subscription are + * captured by `drain`; subscribe BEFORE drain to cover the + * narrow race where a link lands in flight. + */ + onLink(callback: (link: DeepLink) => void): () => void; + }; } const notImplemented = (name: string) => (): Promise => @@ -176,6 +207,19 @@ const bridge: VellumBridge = { }; }, }, + deepLinks: { + drain: (): Promise => + ipcRenderer.invoke("vellum:deepLinks:drain") as Promise, + onLink: (callback) => { + const handler = (_event: IpcRendererEvent, payload: DeepLink) => { + callback(payload); + }; + ipcRenderer.on("vellum:deepLinks:event", handler); + return () => { + ipcRenderer.off("vellum:deepLinks:event", handler); + }; + }, + }, }; contextBridge.exposeInMainWorld("vellum", bridge); diff --git a/apps/web/docs/ELECTRON.md b/apps/web/docs/ELECTRON.md index ee8f06ff988..f98c6657fe3 100644 --- a/apps/web/docs/ELECTRON.md +++ b/apps/web/docs/ELECTRON.md @@ -76,6 +76,15 @@ Example (`runtime/power-events.ts` + `BusEventMap`): the system's `powerMonitor` The bus integration means the same subscriber code works whether the signal came from `powerMonitor` (Electron), `visibilitychange` (web), or Capacitor `appStateChange` (iOS). Wrappers that publish into the bus stay tiny — they're just signal sources. +### When signals can arrive before the renderer exists + +A subset of push signals — inbound deep links being the canonical case — can arrive at the main process BEFORE the renderer has loaded (the OS launches the app via a `vellum://` click → `open-url` fires before `whenReady`). The renderer wrapper grows a second surface for these: + +- **`subscribe(callback)`** — live subscription for post-mount signals. +- **`drainPending()`** — returns and clears the main-side buffer of signals that arrived during startup. + +`use-event-bus-init` calls `subscribe` BEFORE `drainPending` so a signal arriving in flight between the two calls isn't lost. Example: `apps/web/src/runtime/deep-links.ts` paired with the main-side buffer in `apps/macos/src/main/deep-links.ts`. + --- ## See also diff --git a/apps/web/docs/EVENT_BUS.md b/apps/web/docs/EVENT_BUS.md index 43eea324b90..b7811aac217 100644 --- a/apps/web/docs/EVENT_BUS.md +++ b/apps/web/docs/EVENT_BUS.md @@ -70,6 +70,9 @@ which is produced by the burst-limited reachability retry in | `power.lock` | `{}` | Electron host: screen locked. No bus-owned action today. Off Electron never fires. | | `power.unlock` | `{}` | Electron host: screen unlocked. Bus bounces its SSE (same shape as `power.resume`). Off Electron never fires. | | `power.active` | `{}` | Electron host: `user-did-become-active` after idle. No bus-owned action today; future ticket may nudge stale state. Off Electron never fires. | +| `deeplink.send` | `{ message }` | Electron host: inbound `vellum://send?message=…` URL routed by Launch Services. Chat domain consumes to pre-fill the composer. | +| `deeplink.openThread` | `{ threadId }` | Electron host: inbound `vellum://thread/` URL. Chat domain consumes to navigate. | +| `deeplink.unknown` | `{ url }` | Parser fallback for foreign schemes / malformed URLs. Consumers typically log + drop; exists so the bridge surface is exhaustive. | ## Subscribing diff --git a/apps/web/src/hooks/use-event-bus-init.test.tsx b/apps/web/src/hooks/use-event-bus-init.test.tsx index c91ddaab8e8..0e9ac11c67a 100644 --- a/apps/web/src/hooks/use-event-bus-init.test.tsx +++ b/apps/web/src/hooks/use-event-bus-init.test.tsx @@ -48,6 +48,30 @@ mock.module("@/assistant/lifecycle-store", () => ({ }, })); +// Capture the deep-link subscription callback so tests can fire +// "live" links. Allow each test to seed the drain return value. +type DeepLink = + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string }; +let activeDeepLinkCallback: ((link: DeepLink) => void) | null = null; +let pendingDeepLinksFixture: DeepLink[] = []; +const subscribeToDeepLinksMock = mock((cb: (link: DeepLink) => void) => { + activeDeepLinkCallback = cb; + return () => { + activeDeepLinkCallback = null; + }; +}); +const drainPendingDeepLinksMock = mock(async (): Promise => { + const drained = pendingDeepLinksFixture; + pendingDeepLinksFixture = []; + return drained; +}); +mock.module("@/runtime/deep-links", () => ({ + drainPendingDeepLinks: drainPendingDeepLinksMock, + subscribeToDeepLinks: subscribeToDeepLinksMock, +})); + const { useEventBusInit } = await import("@/hooks/use-event-bus-init"); beforeEach(() => { @@ -56,9 +80,13 @@ beforeEach(() => { activeOnError = null; activeOnReconnect = null; lastSubscribeArgs = null; + activeDeepLinkCallback = null; + pendingDeepLinksFixture = []; cancelMock.mockClear(); subscribeChatEventsMock.mockClear(); checkAssistantMock.mockClear(); + subscribeToDeepLinksMock.mockClear(); + drainPendingDeepLinksMock.mockClear(); }); afterEach(() => { @@ -535,3 +563,114 @@ describe("useEventBusInit — DOM event sources", () => { expect(offlineHandler).toHaveBeenCalledTimes(1); }); }); + +describe("useEventBusInit — deep links", () => { + test("publishes deeplink.send for live `send` links via the wrapper subscription", () => { + const handler = mock(() => {}); + useEventBusStore.getState().subscribe("deeplink.send", handler); + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + activeDeepLinkCallback?.({ kind: "send", message: "hi" }); + + expect(handler).toHaveBeenCalledWith({ message: "hi" }); + }); + + test("publishes deeplink.openThread for live `openThread` links", () => { + const handler = mock(() => {}); + useEventBusStore.getState().subscribe("deeplink.openThread", handler); + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + activeDeepLinkCallback?.({ kind: "openThread", threadId: "abc" }); + + expect(handler).toHaveBeenCalledWith({ threadId: "abc" }); + }); + + test("publishes deeplink.unknown for parser-fallback links", () => { + const handler = mock(() => {}); + useEventBusStore.getState().subscribe("deeplink.unknown", handler); + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + activeDeepLinkCallback?.({ kind: "unknown", url: "javascript:alert(1)" }); + + expect(handler).toHaveBeenCalledWith({ url: "javascript:alert(1)" }); + }); + + test("drains the pending buffer at mount and publishes each link in order", async () => { + const sendHandler = mock(() => {}); + const threadHandler = mock(() => {}); + useEventBusStore.getState().subscribe("deeplink.send", sendHandler); + useEventBusStore.getState().subscribe("deeplink.openThread", threadHandler); + pendingDeepLinksFixture = [ + { kind: "send", message: "one" }, + { kind: "openThread", threadId: "thread-1" }, + ]; + + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + // Drain is awaited; let the microtasks settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(sendHandler).toHaveBeenCalledWith({ message: "one" }); + expect(threadHandler).toHaveBeenCalledWith({ threadId: "thread-1" }); + }); + + test("subscribes BEFORE draining so a link arriving mid-drain isn't lost", async () => { + // Trace the subscribe-before-drain order: `subscribeToDeepLinks` + // must be called before `drainPendingDeepLinks` so a link that + // lands in the in-flight window between the two calls still + // reaches the renderer. + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + expect(subscribeToDeepLinksMock).toHaveBeenCalled(); + expect(drainPendingDeepLinksMock).toHaveBeenCalled(); + const subscribeOrder = + subscribeToDeepLinksMock.mock.invocationCallOrder[0]!; + const drainOrder = drainPendingDeepLinksMock.mock.invocationCallOrder[0]!; + expect(subscribeOrder).toBeLessThan(drainOrder); + }); + + test("unsubscribes on unmount so live links stop firing into the bus", () => { + const handler = mock(() => {}); + useEventBusStore.getState().subscribe("deeplink.send", handler); + const { unmount } = renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + }), + ); + + unmount(); + + // After unmount, the captured callback is cleared by the + // unsubscribe-noop returned by `subscribeToDeepLinks`. Verify + // by attempting to fire — should not deliver. + activeDeepLinkCallback?.({ kind: "send", message: "post-unmount" }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/use-event-bus-init.ts b/apps/web/src/hooks/use-event-bus-init.ts index 3bf37170a47..3f29227fd04 100644 --- a/apps/web/src/hooks/use-event-bus-init.ts +++ b/apps/web/src/hooks/use-event-bus-init.ts @@ -3,13 +3,15 @@ * * Two concerns, two effects: * - * 1. DOM / Capacitor / Electron lifecycle. Listens to - * `document.visibilitychange`, `window.online` / `window.offline`, - * Capacitor `App.appStateChange`, and (on the Electron host) the - * main-process `powerMonitor` bridge. Publishes `"app.resume"` / - * `"app.hidden"` / `"app.online"` / `"app.offline"` and - * `"power.suspend"` / `"power.resume"` / `"power.lock"` / - * `"power.unlock"` / `"power.active"` on the bus. + * 1. DOM / Capacitor / Electron lifecycle + inbound deep links. + * Listens to `document.visibilitychange`, `window.online` / + * `window.offline`, Capacitor `App.appStateChange`, and (on the + * Electron host) the main-process `powerMonitor` and deep-link + * bridges. Publishes `"app.resume"` / `"app.hidden"` / + * `"app.online"` / `"app.offline"` / `"power.suspend"` / + * `"power.resume"` / `"power.lock"` / `"power.unlock"` / + * `"power.active"` / `"deeplink.send"` / `"deeplink.openThread"` / + * `"deeplink.unknown"` on the bus. * * 2. Single assistant-scoped SSE connection. Opens one unfiltered * `/v1/events` stream per assistant and re-broadcasts every event @@ -35,6 +37,11 @@ import type { PluginListenerHandle } from "@capacitor/core"; import { useAssistantLifecycleStore } from "@/assistant/lifecycle-store"; import { subscribeChatEvents } from "@/lib/streaming/stream-transport"; import type { ChatEventStream } from "@/lib/streaming/stream-transport"; +import { + drainPendingDeepLinks, + subscribeToDeepLinks, + type DeepLink, +} from "@/runtime/deep-links"; import { subscribeToPowerEvents } from "@/runtime/power-events"; import { useEventBusStore } from "@/stores/event-bus-store"; import { isNativePlatform } from "@/runtime/native-auth"; @@ -131,6 +138,34 @@ export function useEventBusInit({ } }); + // Electron host: deep-link bridge. Subscribe-then-drain order + // matters — a link arriving between drain completion and + // subscription would be lost otherwise. Subscribe first, drain + // second; any in-flight link is delivered via `onLink` and the + // drained buffer carries the pre-renderer-ready backlog. The + // bus delivers handlers in registration order so duplicate + // delivery (live link also enqueued in main between subscribe + // and drain) is consumer's problem if it ever happens — the + // current main-side implementation buffers + broadcasts, so + // drain after subscribe sees no duplicates in practice. + const publishDeepLink = (link: DeepLink) => { + switch (link.kind) { + case "send": + bus.publish("deeplink.send", { message: link.message }); + break; + case "openThread": + bus.publish("deeplink.openThread", { threadId: link.threadId }); + break; + case "unknown": + bus.publish("deeplink.unknown", { url: link.url }); + break; + } + }; + const unsubDeepLinks = subscribeToDeepLinks(publishDeepLink); + void drainPendingDeepLinks().then((pending) => { + for (const link of pending) publishDeepLink(link); + }); + return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("online", handleOnline); @@ -138,6 +173,7 @@ export function useEventBusInit({ appStateCancelled = true; void appStateHandle?.remove(); unsubPower(); + unsubDeepLinks(); }; }, []); diff --git a/apps/web/src/runtime/deep-links.ts b/apps/web/src/runtime/deep-links.ts new file mode 100644 index 00000000000..75e2e5e4b13 --- /dev/null +++ b/apps/web/src/runtime/deep-links.ts @@ -0,0 +1,37 @@ +import { isElectron } from "@/runtime/is-electron"; + +/** + * Per-capability wrapper for the Electron host's deep-link bridge — + * `vellum://` and `vellum-assistant://` URL schemes routed in by + * Launch Services. Same shape as `power-events.ts`: no-op off + * Electron (web build, Capacitor iOS), publishes into the event bus + * via `use-event-bus-init` once it lands, and domain consumers + * subscribe via the bus — never via this wrapper directly. + * + * Two surfaces because deep links can arrive BEFORE the renderer + * exists (the OS launches the app via a `vellum://` click): + * + * - `drainPendingDeepLinks()` — returns the buffer of links + * that arrived during main-process startup, before the renderer + * had a chance to subscribe. + * - `subscribeToDeepLinks(callback)` — subscribes to LIVE links + * (post-renderer-ready). Subscribe BEFORE draining to cover the + * narrow race where a link arrives in flight. + */ + +export type DeepLink = + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string }; + +export async function drainPendingDeepLinks(): Promise { + if (!isElectron()) return []; + return (await window.vellum?.deepLinks.drain()) ?? []; +} + +export function subscribeToDeepLinks( + callback: (link: DeepLink) => void, +): () => void { + if (!isElectron()) return () => undefined; + return window.vellum?.deepLinks.onLink(callback) ?? (() => undefined); +} diff --git a/apps/web/src/runtime/is-electron.ts b/apps/web/src/runtime/is-electron.ts index 79602f90d09..3c763db2fb5 100644 --- a/apps/web/src/runtime/is-electron.ts +++ b/apps/web/src/runtime/is-electron.ts @@ -57,6 +57,23 @@ declare global { }) => void, ): () => void; }; + deepLinks: { + drain(): Promise< + Array< + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string } + > + >; + onLink( + callback: ( + link: + | { kind: "send"; message: string } + | { kind: "openThread"; threadId: string } + | { kind: "unknown"; url: string }, + ) => void, + ): () => void; + }; }; } } diff --git a/apps/web/src/stores/event-bus-store.ts b/apps/web/src/stores/event-bus-store.ts index f174f4101c1..05018d557c0 100644 --- a/apps/web/src/stores/event-bus-store.ts +++ b/apps/web/src/stores/event-bus-store.ts @@ -107,6 +107,23 @@ export interface BusEventMap { "power.lock": Record; "power.unlock": Record; "power.active": Record; + /** + * Inbound deep links from the Electron host — `vellum://` and + * `vellum-assistant://` URL schemes the OS routed to us. Parsed + * into discriminated payloads in `apps/macos/src/main/deep-links.ts`. + * Domain consumers (chat composer, conversation router) subscribe + * here to take action. + * + * `deeplink.unknown` covers parser fallbacks — foreign schemes, + * malformed URLs, unrecognized hosts. Consumers typically log + * and drop these; useful as a no-action signal so the bridge + * surface is exhaustive. + * + * Off Electron these never fire. + */ + "deeplink.send": { message: string }; + "deeplink.openThread": { threadId: string }; + "deeplink.unknown": { url: string }; } export type BusEventName = keyof BusEventMap; From fbdc7bef51aed0171d44d6b76b7921f34b87ee90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 01:56:00 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(macos):=20deep-link=20buffer=20is=20pre?= =?UTF-8?q?-drain-only=20=E2=80=94=20no=20replay=20on=20renderer=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 — the prior implementation pushed EVERY incoming link into `pending[]` AND broadcast it. Live links arriving after the renderer had mounted were handled immediately via the broadcast channel BUT stayed in the buffer. A renderer hard-navigate (e.g. logout) followed by drain on the new renderer would replay already-handled links — composer re-pre-fills with a stale message, navigator re-opens a stale thread, user-visible double-action. Fix: a `drained` flag flips on the first drain call. Before drain, buffer + broadcast (broadcast is wasted, no listeners yet; buffer is what matters). After drain, broadcast only — no buffering, nothing to replay. Caveat called out in the inline doc: a deep link arriving in the narrow window between a renderer hard-navigate and the new renderer's drain is lost. That's the accepted tradeoff — duplicate-action is worse than during-reload lost-link (rare in this app, recoverable by re-clicking). Tests added: - post-drain live links do NOT enter the buffer (renderer-reload scenario: drain backlog, fire a live link, simulate hard navigate, second drain returns empty). - post-drain live links still broadcast (live subscribers still receive them — only the buffer is suppressed, not the broadcast). 24/24 deep-links tests green. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe --- apps/macos/src/main/deep-links.test.ts | 41 ++++++++++++++++++++++++++ apps/macos/src/main/deep-links.ts | 35 +++++++++++++++------- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/macos/src/main/deep-links.test.ts b/apps/macos/src/main/deep-links.test.ts index 4c07ca7637a..309b45b8faa 100644 --- a/apps/macos/src/main/deep-links.test.ts +++ b/apps/macos/src/main/deep-links.test.ts @@ -215,6 +215,47 @@ describe("installDeepLinks", () => { // Second drain returns empty — buffer was cleared. expect(drainHandler()).toEqual([]); }); + + test("post-drain live links do NOT enter the buffer (no replay on renderer reload)", () => { + installDeepLinks(); + const drainHandler = ipcHandleMock.mock.calls.find( + (c) => c[0] === "vellum:deepLinks:drain", + )![1] as () => unknown[]; + + // Backlog before renderer is ready — buffered + broadcast. + handleDeepLink("vellum://send?message=backlog"); + + // Renderer mounts, subscribes, drains the backlog. After this + // call we're in live-only mode. + expect(drainHandler()).toEqual([{ kind: "send", message: "backlog" }]); + + // Live link arrives. It broadcasts (the renderer handles it via + // its live subscription) but must NOT be buffered — a renderer + // reload + second drain would otherwise replay it and double- + // dispatch the user-visible action. + handleDeepLink("vellum://thread/live"); + + // Simulating a renderer hard-navigate: the new renderer mounts, + // subscribes, drains again. Buffer must be empty. + expect(drainHandler()).toEqual([]); + }); + + test("post-drain live links still broadcast (live subscribers still get them)", () => { + installDeepLinks(); + const drainHandler = ipcHandleMock.mock.calls.find( + (c) => c[0] === "vellum:deepLinks:drain", + )![1] as () => unknown[]; + drainHandler(); // switch to live-only mode + + const w = makeWindow(); + windows = [w]; + handleDeepLink("vellum://send?message=live"); + + expect(w.webContents.send).toHaveBeenCalledWith("vellum:deepLinks:event", { + kind: "send", + message: "live", + }); + }); }); describe("handleDeepLink — broadcast", () => { diff --git a/apps/macos/src/main/deep-links.ts b/apps/macos/src/main/deep-links.ts index 3250cb5bf51..d18e957651b 100644 --- a/apps/macos/src/main/deep-links.ts +++ b/apps/macos/src/main/deep-links.ts @@ -102,6 +102,19 @@ export const extractDeepLinkFromArgv = (argv: readonly string[]): string | null const pending: DeepLink[] = []; +// Flips on the first `drain` call. After that, the renderer is +// known to be subscribed (subscribe-then-drain is the wrapper's +// contract), so live links go via broadcast only — buffering them +// would cause a renderer reload / second drainer to replay +// already-handled links and duplicate user-visible actions +// (send-twice, navigate-twice). Caveat: a deep link arriving in +// the narrow window between a renderer hard-navigate (e.g. logout) +// and the new renderer's drain is lost. That's the accepted +// tradeoff — the duplicate-action bug is worse than the +// during-reload lost-link case (which is rare in this app and +// recoverable by re-clicking). +let drained = false; + const broadcast = (link: DeepLink): void => { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue; @@ -110,13 +123,13 @@ const broadcast = (link: DeepLink): void => { }; /** - * Main entry — parse, buffer, broadcast. Internal to this module; - * exposed via the `open-url` / `second-instance` event handlers and - * exported for tests. + * Main entry — parse, buffer-if-pre-drain, broadcast. Internal to + * this module; exposed via the `open-url` / `second-instance` event + * handlers and exported for tests. */ export const handleDeepLink = (input: string): void => { const link = parseVellumUrl(input); - pending.push(link); + if (!drained) pending.push(link); broadcast(link); }; @@ -146,13 +159,14 @@ export const installDeepLinks = (): void => { }); }); - // Renderer drains on mount. Returns AND clears the buffer — live - // links arriving after drain are delivered via the broadcast - // channel above; the renderer's `runtime/deep-links.ts` wrapper - // does subscribe-then-drain to avoid losing in-flight events. + // Renderer drains on mount. Returns AND clears the buffer, then + // switches to live-only mode (see `drained` comment above): future + // links broadcast without buffering, so a renderer reload or + // second drainer can't replay already-delivered events. ipcMain.handle("vellum:deepLinks:drain", (): DeepLink[] => { - const drained = pending.splice(0, pending.length); - return drained; + const out = pending.splice(0, pending.length); + drained = true; + return out; }); }; @@ -160,5 +174,6 @@ export const installDeepLinks = (): void => { // uses `installDeepLinks` instead. export const __resetForTesting = (): void => { installed = false; + drained = false; pending.length = 0; }; From bf959ca7ae88fdd3e39c8af5a9d08d2b149a2d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 02:12:50 +0000 Subject: [PATCH 3/4] fix(macos): deep-link buffering is subscriber-counted, not drain-flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-found bug. The `drained` flag was permanent — once flipped on first drain, links arriving while NO renderer is subscribed (logout, between hard navigates) bypass the buffer because broadcast hits zero listeners and the post-drained "live mode" suppresses buffering. Real-world trace: 1. User logged out, clicks vellum://send?message=hello → buffered. ✓ 2. User logs in → drain returns it. drained = true forever. ✓ 3. User logs out → renderer unmounts. 4. User clicks vellum://thread/abc → broadcast hits zero listeners, drained = true so no buffer → LINK LOST. 5. User logs back in → drain returns []. Thread never opens. The flag conflated "has ever drained" with "is subscribed right now." Fix: explicit subscriber tracking via two new IPC channels: - `vellum:deepLinks:subscribe` — preload sends on `onLink` registration. Main increments `subscriberCount`. - `vellum:deepLinks:unsubscribe` — preload sends on cleanup. Main decrements (clamped to 0). `handleDeepLink` buffers iff `subscriberCount === 0`. `drain` always returns + clears the buffer; no more permanent flag. Closes the logout-relogin lost-link case AND keeps the Codex P2 fix (live links never enter the buffer when a subscriber is listening, so they can't be replayed on renderer reload). Residual race (called out in inline doc): a link arriving in the sub-microsecond window between renderer's `ipcRenderer.on` registration and main's processing of the `subscribe` IPC could be buffered + broadcast. Not realistically triggerable by user action on the same event loop. Tests: - with-subscriber: live links broadcast but don't buffer (Codex P2 stays closed) — verified by subscribe, fire live link, unsubscribe+resubscribe, assert drain is empty. - logout-relogin: link arriving while unsubscribed lands in buffer for the next subscriber (the new bug fix) — subscribe, drain, unsubscribe, fire link, resubscribe, assert drain returns it. - Unsubscribe-before-subscribe clamps to 0 (no negative reference count if cleanup order goes weird). - Live broadcast still reaches subscribed renderers. 26/26 deep-links tests + 34/34 bus-init tests green. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe --- apps/macos/src/main/deep-links.test.ts | 68 +++++++++++++++++++++----- apps/macos/src/main/deep-links.ts | 65 +++++++++++++++--------- apps/macos/src/preload/index.ts | 6 +++ 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/apps/macos/src/main/deep-links.test.ts b/apps/macos/src/main/deep-links.test.ts index 309b45b8faa..d20e511c4ca 100644 --- a/apps/macos/src/main/deep-links.test.ts +++ b/apps/macos/src/main/deep-links.test.ts @@ -12,6 +12,10 @@ const setAsDefaultProtocolClientMock = mock((_scheme: string) => true); const ipcHandleMock = mock( (_channel: string, _handler: (...args: unknown[]) => unknown) => undefined, ); +const ipcOnListeners = new Map(); +const ipcOnMock = mock((event: string, listener: Listener) => { + ipcOnListeners.set(event, listener); +}); let windows: Array<{ isDestroyed: () => boolean; webContents: { send: ReturnType }; @@ -22,7 +26,7 @@ mock.module("electron", () => ({ on: appOnMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, }, - ipcMain: { handle: ipcHandleMock }, + ipcMain: { handle: ipcHandleMock, on: ipcOnMock }, BrowserWindow: { getAllWindows: () => windows }, })); @@ -42,9 +46,11 @@ const makeWindow = (destroyed = false) => ({ beforeEach(() => { __resetForTesting(); appListeners.clear(); + ipcOnListeners.clear(); appOnMock.mockClear(); setAsDefaultProtocolClientMock.mockClear(); ipcHandleMock.mockClear(); + ipcOnMock.mockClear(); windows = []; }); @@ -216,36 +222,72 @@ describe("installDeepLinks", () => { expect(drainHandler()).toEqual([]); }); - test("post-drain live links do NOT enter the buffer (no replay on renderer reload)", () => { + test("with a subscriber present, live links broadcast but do NOT enter the buffer (no replay on renderer reload)", () => { installDeepLinks(); const drainHandler = ipcHandleMock.mock.calls.find( (c) => c[0] === "vellum:deepLinks:drain", )![1] as () => unknown[]; - // Backlog before renderer is ready — buffered + broadcast. + // Backlog before any subscriber. handleDeepLink("vellum://send?message=backlog"); - // Renderer mounts, subscribes, drains the backlog. After this - // call we're in live-only mode. + // Renderer mounts: subscribes, drains. + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); expect(drainHandler()).toEqual([{ kind: "send", message: "backlog" }]); - // Live link arrives. It broadcasts (the renderer handles it via - // its live subscription) but must NOT be buffered — a renderer - // reload + second drain would otherwise replay it and double- - // dispatch the user-visible action. + // Live link arrives while subscribed — broadcasts only. handleDeepLink("vellum://thread/live"); - // Simulating a renderer hard-navigate: the new renderer mounts, - // subscribes, drains again. Buffer must be empty. + // Renderer hard-navigates: unsubscribe, then a new renderer + // mounts and drains. The live link must NOT be replayed. + ipcOnListeners.get("vellum:deepLinks:unsubscribe")?.(); + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); expect(drainHandler()).toEqual([]); }); - test("post-drain live links still broadcast (live subscribers still get them)", () => { + test("logout-relogin: link arriving while unsubscribed lands in the buffer for the next subscriber", () => { installDeepLinks(); const drainHandler = ipcHandleMock.mock.calls.find( (c) => c[0] === "vellum:deepLinks:drain", )![1] as () => unknown[]; - drainHandler(); // switch to live-only mode + + // Renderer mounted and drained the initial empty backlog. + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); + expect(drainHandler()).toEqual([]); + + // User logs out — renderer unmounts. + ipcOnListeners.get("vellum:deepLinks:unsubscribe")?.(); + + // A deep link arrives during the auth flow. No subscribers, + // so it must be buffered. + handleDeepLink("vellum://thread/post-logout"); + + // User logs in — renderer mounts again, subscribes, drains. + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); + expect(drainHandler()).toEqual([ + { kind: "openThread", threadId: "post-logout" }, + ]); + }); + + test("subscribe/unsubscribe IPC accounting is reference-counted and never goes negative", () => { + installDeepLinks(); + const drainHandler = ipcHandleMock.mock.calls.find( + (c) => c[0] === "vellum:deepLinks:drain", + )![1] as () => unknown[]; + + // Unsubscribe before any subscribe — should clamp to 0, not -1. + ipcOnListeners.get("vellum:deepLinks:unsubscribe")?.(); + ipcOnListeners.get("vellum:deepLinks:unsubscribe")?.(); + + handleDeepLink("vellum://send?message=should-buffer"); + expect(drainHandler()).toEqual([ + { kind: "send", message: "should-buffer" }, + ]); + }); + + test("post-drain live links still broadcast (live subscribers still get them)", () => { + installDeepLinks(); + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); const w = makeWindow(); windows = [w]; diff --git a/apps/macos/src/main/deep-links.ts b/apps/macos/src/main/deep-links.ts index d18e957651b..2677287e42d 100644 --- a/apps/macos/src/main/deep-links.ts +++ b/apps/macos/src/main/deep-links.ts @@ -102,18 +102,26 @@ export const extractDeepLinkFromArgv = (argv: readonly string[]): string | null const pending: DeepLink[] = []; -// Flips on the first `drain` call. After that, the renderer is -// known to be subscribed (subscribe-then-drain is the wrapper's -// contract), so live links go via broadcast only — buffering them -// would cause a renderer reload / second drainer to replay -// already-handled links and duplicate user-visible actions -// (send-twice, navigate-twice). Caveat: a deep link arriving in -// the narrow window between a renderer hard-navigate (e.g. logout) -// and the new renderer's drain is lost. That's the accepted -// tradeoff — the duplicate-action bug is worse than the -// during-reload lost-link case (which is rare in this app and -// recoverable by re-clicking). -let drained = false; +// Active renderer subscribers. Renderer calls `vellum:deepLinks:subscribe` +// when its `onLink` handler is registered and `vellum:deepLinks:unsubscribe` +// on cleanup. Buffer when count is zero (no subscribers to receive the +// broadcast); broadcast-only when count > 0. +// +// This is what closes both the Codex P2 (live-link replay on renderer +// reload — broadcast doesn't enter the buffer when a subscriber is +// listening) AND the logout-relogin gap (after the renderer unmounts, +// links arriving during the auth flip land in the buffer and the next +// renderer drains them on mount). A "drained once, never buffer +// again" flag is wrong because it conflates "has ever drained" with +// "is subscribed right now." +// +// Residual race (sub-microsecond, not realistically triggerable by +// user action): a link arriving between the renderer's +// `ipcRenderer.on` registration and main's processing of the +// `subscribe` IPC could be buffered AND broadcast. A single +// renderer-side dedup would catch this if it ever bit; today the +// timing makes it theoretical. +let subscriberCount = 0; const broadcast = (link: DeepLink): void => { for (const win of BrowserWindow.getAllWindows()) { @@ -123,13 +131,13 @@ const broadcast = (link: DeepLink): void => { }; /** - * Main entry — parse, buffer-if-pre-drain, broadcast. Internal to - * this module; exposed via the `open-url` / `second-instance` event - * handlers and exported for tests. + * Main entry — parse, buffer-if-no-subscribers, broadcast. Internal + * to this module; exposed via the `open-url` / `second-instance` + * event handlers and exported for tests. */ export const handleDeepLink = (input: string): void => { const link = parseVellumUrl(input); - if (!drained) pending.push(link); + if (subscriberCount === 0) pending.push(link); broadcast(link); }; @@ -159,14 +167,23 @@ export const installDeepLinks = (): void => { }); }); - // Renderer drains on mount. Returns AND clears the buffer, then - // switches to live-only mode (see `drained` comment above): future - // links broadcast without buffering, so a renderer reload or - // second drainer can't replay already-delivered events. + // Renderer drains on mount. Returns AND clears whatever's in the + // buffer. The next link's `handleDeepLink` decision (buffer vs + // broadcast-only) is governed by `subscriberCount`, not by + // whether drain has been called. ipcMain.handle("vellum:deepLinks:drain", (): DeepLink[] => { - const out = pending.splice(0, pending.length); - drained = true; - return out; + return pending.splice(0, pending.length); + }); + + // Subscriber tracking — see the `subscriberCount` comment above + // for the model. `ipcMain.on` (fire-and-forget) is sufficient — + // these are accounting messages, no return value expected. The + // preload sends them inside `onLink` registration / cleanup. + ipcMain.on("vellum:deepLinks:subscribe", () => { + subscriberCount++; + }); + ipcMain.on("vellum:deepLinks:unsubscribe", () => { + subscriberCount = Math.max(0, subscriberCount - 1); }); }; @@ -174,6 +191,6 @@ export const installDeepLinks = (): void => { // uses `installDeepLinks` instead. export const __resetForTesting = (): void => { installed = false; - drained = false; + subscriberCount = 0; pending.length = 0; }; diff --git a/apps/macos/src/preload/index.ts b/apps/macos/src/preload/index.ts index 33279ba85b7..1eee5f7d27b 100644 --- a/apps/macos/src/preload/index.ts +++ b/apps/macos/src/preload/index.ts @@ -215,8 +215,14 @@ const bridge: VellumBridge = { callback(payload); }; ipcRenderer.on("vellum:deepLinks:event", handler); + // Tell main we're listening so it switches from "buffer" mode + // to "broadcast only" mode. Without this, every live link + // would also enter the buffer and be replayed on a future + // drain (renderer reload, logout-relogin). + ipcRenderer.send("vellum:deepLinks:subscribe"); return () => { ipcRenderer.off("vellum:deepLinks:event", handler); + ipcRenderer.send("vellum:deepLinks:unsubscribe"); }; }, }, From e6641a6802ad80d7929d0999b20f84efc62c7c4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 02:43:21 +0000 Subject: [PATCH 4/4] fix(web): Sentry-capture drainPendingDeepLinks rejections Picks up non-blocking observation from review. The drain promise had no `.catch`, so a downstream `publishDeepLink` / `bus.publish` throw would surface as an unhandled rejection. Mirrors the `appStateChange` Sentry-capture pattern already in the same effect. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe --- apps/web/src/hooks/use-event-bus-init.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/use-event-bus-init.ts b/apps/web/src/hooks/use-event-bus-init.ts index 3f29227fd04..b61f3ca6628 100644 --- a/apps/web/src/hooks/use-event-bus-init.ts +++ b/apps/web/src/hooks/use-event-bus-init.ts @@ -162,9 +162,16 @@ export function useEventBusInit({ } }; const unsubDeepLinks = subscribeToDeepLinks(publishDeepLink); - void drainPendingDeepLinks().then((pending) => { - for (const link of pending) publishDeepLink(link); - }); + void drainPendingDeepLinks() + .then((pending) => { + for (const link of pending) publishDeepLink(link); + }) + .catch((err) => { + Sentry.captureException(err, { + level: "warning", + tags: { context: "deep_link_drain" }, + }); + }); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange);