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..d20e511c4ca --- /dev/null +++ b/apps/macos/src/main/deep-links.test.ts @@ -0,0 +1,344 @@ +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, +); +const ipcOnListeners = new Map(); +const ipcOnMock = mock((event: string, listener: Listener) => { + ipcOnListeners.set(event, listener); +}); +let windows: Array<{ + isDestroyed: () => boolean; + webContents: { send: ReturnType }; +}> = []; + +mock.module("electron", () => ({ + app: { + on: appOnMock, + setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, + }, + ipcMain: { handle: ipcHandleMock, on: ipcOnMock }, + 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(); + ipcOnListeners.clear(); + appOnMock.mockClear(); + setAsDefaultProtocolClientMock.mockClear(); + ipcHandleMock.mockClear(); + ipcOnMock.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([]); + }); + + 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 any subscriber. + handleDeepLink("vellum://send?message=backlog"); + + // Renderer mounts: subscribes, drains. + ipcOnListeners.get("vellum:deepLinks:subscribe")?.(); + expect(drainHandler()).toEqual([{ kind: "send", message: "backlog" }]); + + // Live link arrives while subscribed — broadcasts only. + handleDeepLink("vellum://thread/live"); + + // 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("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[]; + + // 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]; + handleDeepLink("vellum://send?message=live"); + + expect(w.webContents.send).toHaveBeenCalledWith("vellum:deepLinks:event", { + kind: "send", + message: "live", + }); + }); +}); + +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..2677287e42d --- /dev/null +++ b/apps/macos/src/main/deep-links.ts @@ -0,0 +1,196 @@ +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[] = []; + +// 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()) { + if (win.isDestroyed()) continue; + win.webContents.send("vellum:deepLinks:event", link); + } +}; + +/** + * 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 (subscriberCount === 0) 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 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[] => { + 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); + }); +}; + +// Test seam — exported only for unit-test setup. Production code +// uses `installDeepLinks` instead. +export const __resetForTesting = (): void => { + installed = false; + subscriberCount = 0; + 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..1eee5f7d27b 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,25 @@ 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); + // 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"); + }; + }, + }, }; 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..b61f3ca6628 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,41 @@ 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); + }) + .catch((err) => { + Sentry.captureException(err, { + level: "warning", + tags: { context: "deep_link_drain" }, + }); + }); + return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("online", handleOnline); @@ -138,6 +180,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;