diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 08318bcd8da5..c37d36167112 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLink from "./DesktopDeepLink.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -270,6 +271,7 @@ const startup = Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; + const deepLink = yield* DesktopDeepLink.DesktopDeepLink; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; @@ -316,6 +318,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; yield* clerk.configure; + // Before whenReady: macOS emits open-url for a cold-start link as soon as + // the app finishes launching, so the listener has to exist by then. + yield* deepLink.configure; yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), diff --git a/apps/desktop/src/app/DesktopDeepLink.test.ts b/apps/desktop/src/app/DesktopDeepLink.test.ts new file mode 100644 index 000000000000..e7c222d73ae7 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.test.ts @@ -0,0 +1,812 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; +import type { DesktopBridge } from "@t3tools/contracts"; +import * as NodeEvents from "node:events"; + +import { + DEEP_LINK_ACK_CHANNEL, + DEEP_LINK_CHANNEL, + DEEP_LINK_SUBSCRIBE_CHANNEL, + DEEP_LINK_UNSUBSCRIBE_CHANNEL, +} from "../ipc/channels.ts"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopDeepLink from "./DesktopDeepLink.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const preloadElectron = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), +})); +vi.mock("electron", () => ({ + contextBridge: { exposeInMainWorld: preloadElectron.exposeInMainWorld }, + ipcRenderer: preloadElectron, +})); +vi.mock("@clerk/electron/preload", () => ({ exposeClerkBridge: vi.fn() })); + +const ENVIRONMENT_ID = "0f0e2f21-8b4c-4c2a-9d59-3f0d1a2b3c4d"; +const THREAD_ID = "aa11bb22-cc33-4d44-8e55-ff6677889900"; +const OTHER_THREAD_ID = "bb22cc33-dd44-4e55-9f66-001122334455"; +const PAYLOAD = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }; + +describe("parseThreadDeepLink", () => { + it("parses a production-scheme thread link", () => { + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink(`t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`, "t3code"), + PAYLOAD, + ); + }); + + it("accepts the threads host emitted by agent awareness and mobile widgets", () => { + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink( + `t3code://threads/${ENVIRONMENT_ID}/${THREAD_ID}`, + "t3code", + ), + PAYLOAD, + ); + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink( + `t3code-dev://threads/${ENVIRONMENT_ID}/${THREAD_ID}`, + "t3code-dev", + ), + PAYLOAD, + ); + }); + + it("rejects the primary alias rather than guessing an environment", () => { + assert.isNull( + DesktopDeepLink.parseThreadDeepLink(`t3code://threads/primary/${THREAD_ID}`, "t3code"), + ); + }); + + it("accepts an encoded import thread id, as agent awareness emits it", () => { + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink( + `t3code://threads/${ENVIRONMENT_ID}/${encodeURIComponent("import:codex:session-1")}`, + "t3code", + ), + { environmentId: ENVIRONMENT_ID, threadId: "import:codex:session-1" }, + ); + }); + + it("rejects an empty or undecodable thread segment", () => { + assert.isNull( + DesktopDeepLink.parseThreadDeepLink(`t3code://threads/${ENVIRONMENT_ID}/%20`, "t3code"), + ); + assert.isNull( + DesktopDeepLink.parseThreadDeepLink(`t3code://threads/${ENVIRONMENT_ID}/%zz`, "t3code"), + ); + }); + + it("parses a development-scheme thread link", () => { + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink( + `t3code-dev://app/${ENVIRONMENT_ID}/${THREAD_ID}`, + "t3code-dev", + ), + PAYLOAD, + ); + }); + + it("accepts uppercase UUIDs and normalizes them to lowercase", () => { + assert.deepEqual( + DesktopDeepLink.parseThreadDeepLink( + `t3code://app/${ENVIRONMENT_ID.toUpperCase()}/${THREAD_ID.toUpperCase()}`, + "t3code", + ), + PAYLOAD, + ); + }); + + it("rejects links for the other build's scheme", () => { + const path = `app/${ENVIRONMENT_ID}/${THREAD_ID}`; + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`t3code-dev://${path}`, "t3code")); + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`t3code://${path}`, "t3code-dev")); + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`https://${path}`, "t3code")); + }); + + it("rejects hosts other than app and threads", () => { + assert.isNull( + DesktopDeepLink.parseThreadDeepLink(`t3code://apps/${ENVIRONMENT_ID}/${THREAD_ID}`, "t3code"), + ); + assert.isNull( + DesktopDeepLink.parseThreadDeepLink( + `t3code://oauth/${ENVIRONMENT_ID}/${THREAD_ID}`, + "t3code", + ), + ); + }); + + it("rejects wrong path segment counts", () => { + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`t3code://app/${ENVIRONMENT_ID}`, "t3code")); + assert.isNull( + DesktopDeepLink.parseThreadDeepLink( + `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}/${THREAD_ID}`, + "t3code", + ), + ); + }); + + it("rejects non-UUID segments", () => { + assert.isNull( + DesktopDeepLink.parseThreadDeepLink(`t3code://app/settings/${THREAD_ID}`, "t3code"), + ); + assert.isNull( + DesktopDeepLink.parseThreadDeepLink( + `t3code://app/${ENVIRONMENT_ID}/../${THREAD_ID}`, + "t3code", + ), + ); + assert.isNull( + DesktopDeepLink.parseThreadDeepLink( + `t3code://app/${ENVIRONMENT_ID.slice(0, -1)}g/${THREAD_ID}`, + "t3code", + ), + ); + }); + + it("rejects query strings, fragments, and trailing slashes", () => { + const valid = `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`; + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`${valid}?utm_source=slack`, "t3code")); + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`${valid}#section`, "t3code")); + assert.isNull(DesktopDeepLink.parseThreadDeepLink(`${valid}/`, "t3code")); + }); + + it("rejects OAuth callback URLs", () => { + assert.isNull( + DesktopDeepLink.parseThreadDeepLink("t3code://app/oauth/callback?code=abc123", "t3code"), + ); + }); +}); + +describe("findThreadDeepLinkInArgv", () => { + it("finds the thread link among other argv entries", () => { + assert.deepEqual( + DesktopDeepLink.findThreadDeepLinkInArgv( + ["/usr/bin/t3code", "--no-sandbox", `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`], + "t3code", + ), + PAYLOAD, + ); + }); + + it("returns null when argv carries no thread link", () => { + assert.isNull( + DesktopDeepLink.findThreadDeepLinkInArgv( + ["/usr/bin/t3code", "--no-sandbox", "t3code://oauth/callback?code=abc123"], + "t3code", + ), + ); + }); +}); + +type AppListener = (...args: ReadonlyArray) => void | Promise; +type IpcHandler = (raw: unknown, event: DesktopIpc.DesktopIpcInvokeEvent) => Effect.Effect; + +interface FakeSender { + readonly sender: DesktopDeepLink.DeepLinkSender; + readonly send: ReturnType; + readonly isDestroyed: ReturnType; + readonly destroy: () => void; +} + +const makeSender = (id: number): FakeSender => { + const send = vi.fn(); + const isDestroyed = vi.fn(() => false); + const destroyedListeners: Array<() => void> = []; + return { + sender: { + id, + isDestroyed, + send, + once: (_event: "destroyed", listener: () => void) => { + destroyedListeners.push(listener); + }, + }, + send, + isDestroyed, + destroy: () => { + isDestroyed.mockReturnValue(true); + for (const listener of destroyedListeners) { + listener(); + } + }, + }; +}; + +interface TestHarness { + readonly listeners: Map; + readonly ipcHandlers: Map; + readonly revealed: unknown[]; + readonly window: unknown; + readonly deliveries: Promise[]; +} + +const makeHarness = (): TestHarness => ({ + listeners: new Map(), + ipcHandlers: new Map(), + revealed: [], + window: { id: 1 }, + deliveries: [], +}); + +const makeServices = (harness: TestHarness) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + isDevelopment: false, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + const electronApp = { + on: (eventName: string, listener: AppListener) => + Effect.sync(() => { + harness.listeners.set(eventName, (...args) => { + const delivery = listener(...args); + if (delivery !== undefined) harness.deliveries.push(delivery); + return delivery; + }); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + + const electronWindow = { + fromWebContentsId: (_webContentsId: number) => Effect.succeedSome(harness.window), + reveal: (window: unknown) => + Effect.sync(() => { + harness.revealed.push(window); + }), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + const desktopIpc = { + handle: (method: { channel: string; handler: IpcHandler }) => + Effect.sync(() => { + harness.ipcHandlers.set(method.channel, method.handler); + }), + } as unknown as DesktopIpc.DesktopIpc["Service"]; + + return { + layer: DesktopDeepLink.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ), + electronApp, + electronWindow, + desktopIpc, + }; +}; + +const configureWith = ( + services: ReturnType, + overrides?: { + readonly processArguments?: ReadonlyArray; + readonly earlyCapture?: DesktopDeepLink.EarlyOpenUrlCapture; + }, +) => { + let program = Effect.gen(function* () { + const deepLink = yield* DesktopDeepLink.DesktopDeepLink; + yield* Effect.scoped(deepLink.configure); + }).pipe( + Effect.provide(services.layer), + Effect.provideService(ElectronApp.ElectronApp, services.electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, services.electronWindow), + Effect.provideService(DesktopIpc.DesktopIpc, services.desktopIpc), + ); + if (overrides?.processArguments !== undefined) { + program = program.pipe(Effect.provideService(HostProcessArguments, overrides.processArguments)); + } + if (overrides?.earlyCapture !== undefined) { + program = program.pipe( + Effect.provideService(DesktopDeepLink.EarlyOpenUrlCapture, overrides.earlyCapture), + ); + } + return program; +}; + +const subscribeAs = (harness: TestHarness, fake: FakeSender) => + Effect.gen(function* () { + const handler = harness.ipcHandlers.get(DEEP_LINK_SUBSCRIBE_CHANNEL); + assert.isDefined(handler); + const result = yield* handler!(undefined, { sender: fake.sender }); + return typeof result === "object" && result !== null && "payload" in result + ? result.payload + : result; + }); + +const unsubscribeAs = (harness: TestHarness, fake: FakeSender) => + Effect.gen(function* () { + const handler = harness.ipcHandlers.get(DEEP_LINK_UNSUBSCRIBE_CHANNEL); + assert.isDefined(handler); + return yield* handler!(undefined, { sender: fake.sender }); + }); + +// Await the actual operation returned by each registered OS listener. +const settleDelivery = (harness: TestHarness) => + Effect.promise(() => Promise.all(harness.deliveries.splice(0))); + +describe("DesktopDeepLink", () => { + it.effect( + "keeps a link buffered when an ID-only IPC sender tries to subscribe or acknowledge", + () => { + const harness = makeHarness(); + const renderer = makeSender(7); + return Effect.gen(function* () { + yield* configureWith(makeServices(harness), { + processArguments: ["t3code", `t3code://threads/${ENVIRONMENT_ID}/${THREAD_ID}`], + }); + const event = { sender: { id: 7 } }; + assert.deepEqual( + yield* harness.ipcHandlers.get(DEEP_LINK_SUBSCRIBE_CHANNEL)!(undefined, event), + { payload: null, generation: 1 }, + ); + yield* harness.ipcHandlers.get(DEEP_LINK_ACK_CHANNEL)!(1, event); + yield* harness.ipcHandlers.get(DEEP_LINK_UNSUBSCRIBE_CHANNEL)!(undefined, event); + assert.deepEqual(yield* subscribeAs(harness, renderer), PAYLOAD); + }); + }, + ); + + it.effect( + "keeps the real preload subscribed across a remount with a delayed subscribe reply", + () => + Effect.gen(function* () { + const harness = makeHarness(); + const renderer = makeSender(7); + const events = new NodeEvents.EventEmitter(); + let replyGate = Promise.withResolvers(); + let firstSubscribed = Promise.withResolvers(); + const calls: Promise[] = []; + let first = true; + yield* configureWith(makeServices(harness), { + processArguments: ["t3code", `t3code://threads/${ENVIRONMENT_ID}/${THREAD_ID}`], + }); + const context = yield* Effect.context(); + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The real preload invokes Promise-based Electron IPC; bridge its calls into the test context just as DesktopIpc does. + const runIpc = Effect.runPromiseWith(context); + preloadElectron.on.mockImplementation((channel, listener) => events.on(channel, listener)); + preloadElectron.removeListener.mockImplementation((channel, listener) => + events.removeListener(channel, listener), + ); + renderer.send.mockImplementation((channel, ...args) => events.emit(channel, {}, ...args)); + preloadElectron.invoke.mockImplementation((channel: string, raw: unknown) => { + const handler = harness.ipcHandlers.get(channel); + assert.isDefined(handler); + const delayReply = channel === DEEP_LINK_SUBSCRIBE_CHANNEL && first; + if (delayReply) first = false; + const call = runIpc(handler!(raw, { sender: renderer.sender })).then(async (result) => { + if (delayReply) { + firstSubscribed.resolve(); + await replyGate.promise; + } + return result; + }); + calls.push(call); + return call; + }); + yield* Effect.acquireRelease( + Effect.sync(() => vi.stubGlobal("window", { addEventListener: vi.fn() })), + () => Effect.sync(() => vi.unstubAllGlobals()), + ); + yield* Effect.promise(() => import("../preload.ts")); + const bridge = preloadElectron.exposeInMainWorld.mock.calls.find( + ([name]) => name === "desktopBridge", + )?.[1] as DesktopBridge; + assert.isDefined(bridge); + const discarded = vi.fn(); + const active = vi.fn(); + const cleanup = bridge.onDeepLink!(discarded); + yield* Effect.promise(() => firstSubscribed.promise); + cleanup(); + const cleanupReplacement = bridge.onDeepLink!(active); + replyGate.resolve(); + // Drain concrete IPC operations, including acknowledgments created + // by their replies. No timer can stand in for completion of these handlers. + while (calls.length > 0) yield* Effect.promise(() => Promise.all(calls.splice(0))); + assert.equal(discarded.mock.calls.length, 0); + assert.deepEqual(active.mock.calls, [[PAYLOAD]]); + active.mockClear(); + + yield* Effect.promise(() => + Promise.resolve( + harness.listeners.get("open-url")!( + { preventDefault: vi.fn() }, + `t3code://threads/${ENVIRONMENT_ID}/${OTHER_THREAD_ID}`, + ), + ), + ); + while (calls.length > 0) yield* Effect.promise(() => Promise.all(calls.splice(0))); + assert.deepEqual(active.mock.calls, [ + [{ environmentId: ENVIRONMENT_ID, threadId: OTHER_THREAD_ID }], + ]); + cleanupReplacement(); + while (calls.length > 0) yield* Effect.promise(() => Promise.all(calls.splice(0))); + yield* Effect.promise(() => + Promise.resolve( + harness.listeners.get("open-url")!( + { preventDefault: vi.fn() }, + `t3code://threads/${ENVIRONMENT_ID}/${THREAD_ID}`, + ), + ), + ); + assert.equal(active.mock.calls.length, 1); + assert.deepEqual(yield* subscribeAs(harness, renderer), PAYLOAD); + + // A live listener can receive a newer push before its buffered reply. + // The delayed reply must not navigate back to the older thread. + first = true; + replyGate = Promise.withResolvers(); + firstSubscribed = Promise.withResolvers(); + const latest = vi.fn(); + const cleanupLatest = bridge.onDeepLink!(latest); + yield* Effect.promise(() => firstSubscribed.promise); + yield* Effect.promise(() => + Promise.resolve( + harness.listeners.get("open-url")!( + { preventDefault: vi.fn() }, + `t3code://threads/${ENVIRONMENT_ID}/${OTHER_THREAD_ID}`, + ), + ), + ); + replyGate.resolve(); + while (calls.length > 0) yield* Effect.promise(() => Promise.all(calls.splice(0))); + assert.deepEqual(latest.mock.calls, [ + [{ environmentId: ENVIRONMENT_ID, threadId: OTHER_THREAD_ID }], + ]); + cleanupLatest(); + while (calls.length > 0) yield* Effect.promise(() => Promise.all(calls.splice(0))); + }), + ); + + it.effect("registers the OS listeners and the subscribe handler", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + + assert.deepEqual([...harness.listeners.keys()], ["open-url", "second-instance"]); + assert.deepEqual( + [...harness.ipcHandlers.keys()], + [DEEP_LINK_ACK_CHANNEL, DEEP_LINK_SUBSCRIBE_CHANNEL, DEEP_LINK_UNSUBSCRIBE_CHANNEL], + ); + }); + }); + + it.effect("pushes a valid open-url link to the subscribed renderer", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + + assert.isNull(yield* subscribeAs(harness, renderer)); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + const preventDefault = vi.fn(); + openUrl!({ preventDefault }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + assert.equal(preventDefault.mock.calls.length, 1); + assert.deepEqual(harness.revealed, [harness.window]); + assert.deepEqual(renderer.send.mock.calls, [[DEEP_LINK_CHANNEL, PAYLOAD, 1]]); + }); + }); + + it.effect( + "retains an unacknowledged push across teardown and ignores stale acknowledgments", + () => { + const harness = makeHarness(); + const renderer = makeSender(7); + const next = makeSender(8); + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, renderer); + const openUrl = harness.listeners.get("open-url")!; + openUrl({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + yield* unsubscribeAs(harness, renderer); + assert.deepEqual(yield* subscribeAs(harness, next), PAYLOAD); + openUrl({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${OTHER_THREAD_ID}`); + yield* settleDelivery(harness); + yield* harness.ipcHandlers.get(DEEP_LINK_ACK_CHANNEL)!(1, { sender: next.sender }); + assert.deepEqual(yield* subscribeAs(harness, next), { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }); + yield* harness.ipcHandlers.get(DEEP_LINK_ACK_CHANNEL)!(2, { sender: next.sender }); + assert.isNull(yield* subscribeAs(harness, next)); + }); + }, + ); + + it.effect("leaves OAuth callback URLs untouched", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, renderer); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + const preventDefault = vi.fn(); + openUrl!({ preventDefault }, "t3code://app/oauth/callback?code=abc123"); + openUrl!({ preventDefault }, "https://clerk.t3.codes/v1/oauth_callback?code=abc123"); + yield* settleDelivery(harness); + + assert.equal(preventDefault.mock.calls.length, 0); + assert.deepEqual(harness.revealed, []); + assert.equal(renderer.send.mock.calls.length, 0); + }); + }); + + it.effect("delivers a thread link found in second-instance argv", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, renderer); + + const secondInstance = harness.listeners.get("second-instance"); + assert.isDefined(secondInstance); + secondInstance!( + {}, + ["/usr/bin/t3code", "--allow", `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`], + "/tmp", + ); + yield* settleDelivery(harness); + + assert.deepEqual(renderer.send.mock.calls, [[DEEP_LINK_CHANNEL, PAYLOAD, 1]]); + }); + }); + + it.effect( + "buffers while only unsubscribed windows exist and hands the link to the first subscriber", + () => { + const harness = makeHarness(); + // The splash window's webContents never subscribes, so it must never + // receive a push and must not swallow the link. + const splash = makeSender(3); + const renderer = makeSender(7); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + assert.equal(splash.send.mock.calls.length, 0); + assert.equal(renderer.send.mock.calls.length, 0); + assert.deepEqual(harness.revealed, []); + + // A teardown before acknowledgment must not consume the buffered link. + assert.deepEqual(yield* subscribeAs(harness, renderer), PAYLOAD); + assert.deepEqual(yield* subscribeAs(harness, renderer), PAYLOAD); + yield* harness.ipcHandlers.get(DEEP_LINK_ACK_CHANNEL)!(1, { sender: renderer.sender }); + assert.isNull(yield* subscribeAs(harness, renderer)); + }); + }, + ); + + it.effect("keeps only the latest link while buffering", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${OTHER_THREAD_ID}`); + yield* settleDelivery(harness); + + assert.deepEqual(yield* subscribeAs(harness, renderer), { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }); + }); + }); + + it.effect("drops destroyed subscribers and buffers for the next one", () => { + const harness = makeHarness(); + const first = makeSender(7); + const second = makeSender(9); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + const onDestroyed = vi.spyOn(first.sender, "once"); + for (let cycle = 0; cycle < 12; cycle += 1) { + yield* subscribeAs(harness, first); + yield* unsubscribeAs(harness, first); + } + yield* subscribeAs(harness, first); + assert.equal(onDestroyed.mock.calls.length, 1); + first.destroy(); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + assert.equal(first.send.mock.calls.length, 0); + assert.deepEqual(yield* subscribeAs(harness, second), PAYLOAD); + }); + }); + + it.effect( + "stops pushing to a renderer that unsubscribed while its webContents stays alive", + () => { + const harness = makeHarness(); + // A renderer whose deep-link component unmounts (navigating to /connect + // or /pair) tears down its listener and unsubscribes, but its + // webContents is not destroyed. It must not swallow the next link. + const renderer = makeSender(7); + const next = makeSender(9); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, renderer); + yield* unsubscribeAs(harness, renderer); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + // The link was buffered, not pushed into the listener-less renderer. + assert.equal(renderer.send.mock.calls.length, 0); + assert.deepEqual(harness.revealed, []); + assert.deepEqual(yield* subscribeAs(harness, next), PAYLOAD); + }); + }, + ); + + it.effect("re-buffers when the subscriber dies between selection and send", () => { + const harness = makeHarness(); + const dying = makeSender(7); + const next = makeSender(9); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, dying); + // Alive when picked from the registry, destroyed by send time. + dying.isDestroyed.mockReturnValueOnce(false).mockReturnValue(true); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + assert.equal(dying.send.mock.calls.length, 0); + assert.deepEqual(yield* subscribeAs(harness, next), PAYLOAD); + }); + }); + + it.effect("re-buffers when the subscriber send throws during destruction", () => { + const harness = makeHarness(); + const dying = makeSender(7); + const next = makeSender(9); + + return Effect.gen(function* () { + yield* configureWith(makeServices(harness)); + yield* subscribeAs(harness, dying); + dying.send.mockImplementation(() => { + throw new Error("Object has been destroyed"); + }); + + const openUrl = harness.listeners.get("open-url"); + assert.isDefined(openUrl); + openUrl!({ preventDefault: vi.fn() }, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + yield* settleDelivery(harness); + + assert.equal(dying.send.mock.calls.length, 1); + assert.deepEqual(yield* subscribeAs(harness, next), PAYLOAD); + }); + }); + + it.effect("delivers a cold-start link from injected process arguments", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + + return Effect.gen(function* () { + // Windows/Linux cold start: the primary instance's own argv carries the + // protocol URL. Injected here rather than read from the ambient argv. + yield* configureWith(makeServices(harness), { + processArguments: [ + "/usr/bin/t3code", + "--allow-file-access-from-files", + `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`, + ], + }); + + assert.deepEqual(yield* subscribeAs(harness, renderer), PAYLOAD); + }); + }); + + it.effect("drains URLs stashed by the early capture, latest first", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + const removeListener = vi.fn(); + let captured: ((event: unknown, url: string) => void) | null = null; + const source = { + on: (_event: "open-url", listener: (event: unknown, url: string) => void) => { + captured = listener; + }, + removeListener, + }; + + return Effect.gen(function* () { + const earlyCapture = DesktopDeepLink.captureEarlyOpenUrls(source); + assert.isNotNull(captured); + captured!({}, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + captured!({}, `t3code://app/${ENVIRONMENT_ID}/${OTHER_THREAD_ID}`); + captured!({}, "t3code://app/oauth/callback?code=abc123"); + + yield* configureWith(makeServices(harness), { earlyCapture }); + + assert.equal(removeListener.mock.calls.length, 1); + assert.deepEqual(yield* subscribeAs(harness, renderer), { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }); + }); + }); + + it.effect("a drained capture yields no cold-start link to a later service", () => { + const harness = makeHarness(); + const renderer = makeSender(7); + let captured: ((event: unknown, url: string) => void) | null = null; + const source = { + on: (_event: "open-url", listener: (event: unknown, url: string) => void) => { + captured = listener; + }, + removeListener: vi.fn(), + }; + + return Effect.gen(function* () { + const earlyCapture = DesktopDeepLink.captureEarlyOpenUrls(source); + captured!({}, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + + // The first service drains the capture; a later service built against + // the same handle must see an empty buffer, not a stale link. + yield* configureWith(makeServices(makeHarness()), { earlyCapture }); + yield* configureWith(makeServices(harness), { earlyCapture }); + + assert.isNull(yield* subscribeAs(harness, renderer)); + }); + }); +}); + +describe("captureEarlyOpenUrls", () => { + it("captures independently per handle and drains each once", () => { + const listeners: Array<(event: unknown, url: string) => void> = []; + const removeListener = vi.fn(); + const source = { + on: (_event: "open-url", listener: (event: unknown, url: string) => void) => { + listeners.push(listener); + }, + removeListener, + }; + + const first = DesktopDeepLink.captureEarlyOpenUrls(source); + const second = DesktopDeepLink.captureEarlyOpenUrls(source); + + // Both handles hear the same early event... + assert.equal(listeners.length, 2); + for (const listener of listeners) { + listener({}, `t3code://app/${ENVIRONMENT_ID}/${THREAD_ID}`); + } + // ...and each drains its own buffer exactly once. + assert.equal(first.drain().length, 1); + assert.deepEqual(first.drain(), []); + assert.equal(second.drain().length, 1); + assert.deepEqual(second.drain(), []); + assert.equal(removeListener.mock.calls.length, 2); + }); +}); diff --git a/apps/desktop/src/app/DesktopDeepLink.ts b/apps/desktop/src/app/DesktopDeepLink.ts new file mode 100644 index 000000000000..c5040add0f30 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.ts @@ -0,0 +1,382 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import type { DesktopThreadDeepLinkPayload } from "@t3tools/contracts"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; +import { + DEEP_LINK_ACK_CHANNEL, + DEEP_LINK_CHANNEL, + DEEP_LINK_SUBSCRIBE_CHANNEL, + DEEP_LINK_UNSUBSCRIBE_CHANNEL, +} from "../ipc/channels.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-deep-link"); + +const UUID_SEGMENT = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const THREAD_ID_SEGMENT = "[^/?#]+"; + +/** + * Parses a thread deep link of exactly the shape + * `://threads//` (or host `app`). + * + * The environment id is always a server-generated UUID. The thread id is one + * URI-encoded segment that decodes to a non-empty string — besides UUIDs the + * app emits `import::` ids for imported threads, and agent + * awareness ships them URI-encoded in `/threads/...` links. + * + * The scheme and host compare case-insensitively (URL semantics) and UUID + * casing is normalized to lowercase. Everything else is rejected — extra or + * missing path segments, a non-UUID environment, an empty thread segment, + * query strings, fragments, and trailing slashes — so the URL scheme cannot + * become an arbitrary-navigation primitive and OAuth callback URLs pass + * through to their own listeners. + */ +export function parseThreadDeepLink( + url: string, + scheme: string, +): DesktopThreadDeepLinkPayload | null { + const pattern = new RegExp( + `^${scheme}://(?:${ElectronProtocol.DESKTOP_HOST}|threads)/(${UUID_SEGMENT})/(${THREAD_ID_SEGMENT})$`, + "i", + ); + const match = pattern.exec(url); + if (match === null || match[1] === undefined || match[2] === undefined) { + return null; + } + let threadId: string; + try { + threadId = decodeURIComponent(match[2]); + } catch { + return null; + } + if (threadId.trim().length === 0) { + return null; + } + return { + environmentId: match[1].toLowerCase(), + threadId: new RegExp(`^${UUID_SEGMENT}$`, "i").test(threadId) + ? threadId.toLowerCase() + : threadId, + }; +} + +/** + * Windows and Linux deliver protocol URLs as a command-line argument rather + * than an event, so the URL has to be fished out of argv. + */ +export function findThreadDeepLinkInArgv( + argv: ReadonlyArray, + scheme: string, +): DesktopThreadDeepLinkPayload | null { + for (const entry of argv) { + const payload = parseThreadDeepLink(entry, scheme); + if (payload !== null) { + return payload; + } + } + return null; +} + +interface EarlyOpenUrlSource { + on(event: "open-url", listener: (event: unknown, url: string) => void): unknown; + removeListener(event: "open-url", listener: (event: unknown, url: string) => void): unknown; +} + +export interface EarlyOpenUrlCapture { + /** + * Detach the early listener and return the raw URLs captured so far. + * Draining a handle twice returns nothing the second time. + */ + readonly drain: () => ReadonlyArray; +} + +const emptyEarlyOpenUrlCapture: EarlyOpenUrlCapture = { drain: () => [] }; + +/** + * The capture handle created by `main.ts` before the Effect runtime exists + * and provided to the deep-link layer. Defaults to an empty capture so + * entry points and tests that never early-capture need no setup. + */ +export const EarlyOpenUrlCapture = Context.Reference( + "@t3tools/desktop/app/DesktopDeepLink/EarlyOpenUrlCapture", + { defaultValue: () => emptyEarlyOpenUrlCapture }, +); + +/** + * Must run at the earliest synchronous point of main-process startup: macOS + * can emit the cold-start open-url before the deep-link service's own + * listener exists, and an unheard event is simply lost. Capture only — no + * parsing, no preventDefault — so OAuth callbacks and every other URL keep + * flowing to their own listeners. The returned handle is an explicit input + * to the service; `configure` drains it through the normal parse path, + * which also detaches this listener. + */ +export function captureEarlyOpenUrls(source: EarlyOpenUrlSource): EarlyOpenUrlCapture { + const urls: string[] = []; + const listener = (_event: unknown, url: string) => { + if (typeof url === "string") { + urls.push(url); + } + }; + source.on("open-url", listener); + let drained = false; + return { + drain: () => { + if (drained) return []; + drained = true; + source.removeListener("open-url", listener); + return [...urls]; + }, + }; +} + +// Only deep-link subscribers need the live WebContents operations; ordinary +// IPC methods keep the upstream ID-only sender contract. +export interface DeepLinkSender { + readonly id: number; + isDestroyed(): boolean; + send(channel: string, ...args: ReadonlyArray): void; + once(event: "destroyed", listener: () => void): unknown; +} + +function deepLinkSender(sender: { readonly id: number } | undefined): DeepLinkSender | undefined { + if ( + sender !== undefined && + "isDestroyed" in sender && + typeof sender.isDestroyed === "function" && + "send" in sender && + typeof sender.send === "function" && + "once" in sender && + typeof sender.once === "function" + ) { + return sender as DeepLinkSender; + } + return undefined; +} + +export class DesktopDeepLink extends Context.Service< + DesktopDeepLink, + { + readonly configure: Effect.Effect< + void, + never, + ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | DesktopIpc.DesktopIpc | Scope.Scope + >; + } +>()("@t3tools/desktop/app/DesktopDeepLink") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const earlyCapture = yield* EarlyOpenUrlCapture; + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + + // Retain the newest link until a renderer acknowledges listener delivery. + const pending = yield* Ref.make>(Option.none()); + const generation = yield* Ref.make(0); + + // Renderer webContents that completed the subscribe handshake, oldest + // first. A subscriber has mounted its deep-link listener, so a push cannot + // race the renderer's startup — and the WSL splash never subscribes, so it + // can never swallow a link. Entries drop three ways: the renderer explicitly + // unsubscribes when its listener tears down (e.g. navigating to /connect or + // /pair, which unmounts the deep-link component while the webContents stays + // alive), the webContents fires destroyed, or a stale entry is pruned on use. + const subscribers: DeepLinkSender[] = []; + const observedSenders = new WeakSet(); + + const removeSubscriber = (sender: DeepLinkSender) => { + const index = subscribers.indexOf(sender); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + + const latestLiveSubscriber = (): DeepLinkSender | null => { + for (let index = subscribers.length - 1; index >= 0; index -= 1) { + const subscriber = subscribers[index]; + if (subscriber === undefined || subscriber.isDestroyed()) { + subscribers.splice(index, 1); + continue; + } + return subscriber; + } + return null; + }; + + const subscribe = (sender: DeepLinkSender | undefined) => + Effect.gen(function* () { + const currentGeneration = yield* Ref.get(generation); + if (sender === undefined || sender.isDestroyed()) { + return { payload: null, generation: currentGeneration }; + } + yield* Effect.sync(() => { + if (!subscribers.includes(sender)) { + subscribers.push(sender); + } + if (!observedSenders.has(sender)) { + observedSenders.add(sender); + sender.once("destroyed", () => removeSubscriber(sender)); + } + }); + const payload = yield* Ref.get(pending); + return { + payload: Option.getOrNull(payload), + generation: currentGeneration, + }; + }); + + // The renderer's preload invokes this when its deep-link listener tears + // down. Without it a reloaded or route-unmounted renderer would stay in the + // registry with a live webContents but no listener, so a link would be + // pushed into a void and lost instead of buffered for the next subscriber. + const unsubscribe = (sender: DeepLinkSender | undefined) => + Effect.sync(() => { + if (sender !== undefined) { + removeSubscriber(sender); + } + return null; + }); + + return DesktopDeepLink.of({ + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const ipc = yield* DesktopIpc.DesktopIpc; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const open = (payload: DesktopThreadDeepLinkPayload) => + Effect.gen(function* () { + const deliveryGeneration = yield* Ref.updateAndGet(generation, (value) => value + 1); + yield* Ref.set(pending, Option.some(payload)); + const subscriber = latestLiveSubscriber(); + if (subscriber === null) { + yield* logInfo("thread deep link buffered until a renderer subscribes", { + environmentId: payload.environmentId, + threadId: payload.threadId, + }); + return; + } + const window = yield* electronWindow.fromWebContentsId(subscriber.id); + if (Option.isSome(window)) { + yield* electronWindow.reveal(window.value); + } + const delivered = yield* Effect.sync(() => { + if (subscriber.isDestroyed()) return false; + try { + subscriber.send(DEEP_LINK_CHANNEL, payload, deliveryGeneration); + return true; + } catch { + return false; + } + }); + if (!delivered) { + // The subscriber died between selection and send; keep the link + // for the next subscriber instead of dropping it. + removeSubscriber(subscriber); + return; + } + yield* logInfo("thread deep link sent to renderer", { + environmentId: payload.environmentId, + threadId: payload.threadId, + }); + }); + + yield* ipc + .handle({ + channel: DEEP_LINK_ACK_CHANNEL, + handler: (raw, event) => + Effect.gen(function* () { + if (typeof raw !== "number" || !Number.isSafeInteger(raw)) return null; + if ( + event?.sender === undefined || + !subscribers.some((sender) => sender === event.sender) + ) + return null; + if ((yield* Ref.get(generation)) === raw) yield* Ref.set(pending, Option.none()); + return null; + }), + }) + .pipe( + Effect.catch((error) => + logWarning("deep link acknowledgment registration failed", { message: error.message }), + ), + ); + + // The renderer's preload invokes this once its deep-link listener is + // mounted: register it for future pushes and hand back the buffered + // link, if any (cold start). + yield* ipc + .handle({ + channel: DEEP_LINK_SUBSCRIBE_CHANNEL, + handler: (_raw, event) => subscribe(deepLinkSender(event?.sender)), + }) + .pipe( + // Deep links must never block startup. + Effect.catch((error) => + logWarning("deep link subscribe handler registration failed", { + message: error.message, + }), + ), + ); + + // The preload invokes this as its listener tears down so a renderer that + // is still alive but no longer listening (route unmount, reload) stops + // being a delivery target and links buffer for the next subscriber. + yield* ipc + .handle({ + channel: DEEP_LINK_UNSUBSCRIBE_CHANNEL, + handler: (_raw, event) => unsubscribe(deepLinkSender(event?.sender)), + }) + .pipe( + Effect.catch((error) => + logWarning("deep link unsubscribe handler registration failed", { + message: error.message, + }), + ), + ); + + // macOS delivers protocol URLs as open-url events. OAuth callback URLs + // must keep flowing to the Clerk bridge listeners, so anything that is + // not exactly a thread link is left untouched — no preventDefault. + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + const payload = parseThreadDeepLink(url, scheme); + if (payload === null) return; + event.preventDefault(); + return runPromise(open(payload)); + }); + + // Windows/Linux forward protocol URLs from secondary instances as argv. + yield* electronApp.on("second-instance", (_event: unknown, argv: ReadonlyArray) => { + const payload = findThreadDeepLinkInArgv(argv, scheme); + if (payload === null) return; + return runPromise(open(payload)); + }); + + // Cold starts: URLs stashed by the early capture before this service + // existed (macOS; latest wins) and the primary instance's own command + // line (Windows/Linux). + const processArguments = yield* HostProcessArguments; + const earlyUrls = earlyCapture.drain().toReversed(); + const initial = + findThreadDeepLinkInArgv(earlyUrls, scheme) ?? + findThreadDeepLinkInArgv(processArguments, scheme); + if (initial !== null) { + yield* open(initial); + } + }).pipe(Effect.withSpan("desktop.deepLink.configure")), + }); +}); + +export const layer = Layer.effect(DesktopDeepLink, make); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 98e9cf364690..c63294041866 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -70,6 +70,7 @@ function layerElectronWindow(destroyAll: Effect.Effect = Effect.void) { clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, + fromWebContentsId: () => Effect.die("unexpected webContents lookup"), sendAll: () => Effect.void, destroyAll, syncAllAppearance: () => Effect.void, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 1c8d82eb2cf9..80cabbc03869 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -119,6 +119,9 @@ export class ElectronWindow extends Context.Service< readonly clearMain: (window: Option.Option) => Effect.Effect; readonly prepareReveal: (window: Electron.BrowserWindow) => Effect.Effect; readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly fromWebContentsId: ( + webContentsId: number, + ) => Effect.Effect>; readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; readonly syncAllAppearance: ( @@ -327,6 +330,24 @@ export const make = Effect.gen(function* () { cause, }), }).pipe(Effect.orDie), + fromWebContentsId: (webContentsId) => + Effect.try({ + try: () => { + const contents = Electron.webContents.fromId(webContentsId); + if (contents === undefined || contents.isDestroyed()) { + return Option.none(); + } + return Option.fromNullishOr(Electron.BrowserWindow.fromWebContents(contents) ?? null); + }, + catch: (cause) => + new ElectronWindowOperationError({ + operation: "inspect-window", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie), sendAll: (channel, ...args) => Effect.gen(function* () { for (const window of yield* listWindows) { diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 66b4b48a183d..1ff75d9ee6af 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,10 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const DEEP_LINK_ACK_CHANNEL = "desktop:deep-link:ack"; +export const DEEP_LINK_CHANNEL = "desktop:deep-link"; +export const DEEP_LINK_SUBSCRIBE_CHANNEL = "desktop:deep-link:subscribe"; +export const DEEP_LINK_UNSUBSCRIBE_CHANNEL = "desktop:deep-link:unsubscribe"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2c56f6b39c8e..7c580c76ddac 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -44,6 +44,7 @@ import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfigurat import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; +import * as DesktopDeepLink from "./app/DesktopDeepLink.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; @@ -83,6 +84,10 @@ if (process.argv.includes("--version")) { Electron.app.exit(0); } +// macOS can emit the cold-start open-url before the layer stack is built; +// stash raw URLs now and hand the capture to the deep-link layer below. +const earlyOpenUrlCapture = DesktopDeepLink.captureEarlyOpenUrls(Electron.app); + const layerDesktopEnvironment = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -208,10 +213,15 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( Layer.provideMerge(layerDesktopBackend), ); +const layerDesktopDeepLink = DesktopDeepLink.layer.pipe( + Layer.provide(Layer.succeed(DesktopDeepLink.EarlyOpenUrlCapture, earlyOpenUrlCapture)), +); + const layerDesktopApplication = Layer.mergeAll( DesktopLifecycle.layer, layerDesktopAppActivation, DesktopApplicationMenu.layer, + layerDesktopDeepLink, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, layerDesktopSsh, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5ca77997af40..84edcb31258f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopThreadDeepLinkPayload, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, @@ -217,6 +218,68 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onDeepLink: (listener) => { + let active = true; + let deliveredGeneration = -1; + const parsePayload = (payload: unknown): DesktopThreadDeepLinkPayload | null => { + if (typeof payload !== "object" || payload === null) return null; + const { environmentId, threadId } = payload as { + environmentId?: unknown; + threadId?: unknown; + }; + if (typeof environmentId !== "string" || typeof threadId !== "string") return null; + return { environmentId, threadId }; + }; + const deliver = (payload: unknown, generation: unknown) => { + if (!active) return; + const parsed = parsePayload(payload); + if (parsed !== null) { + if (typeof generation === "number") { + if (generation <= deliveredGeneration) return; + deliveredGeneration = generation; + } + listener(parsed); + if (typeof generation === "number") { + void ipcRenderer + .invoke(IpcChannels.DEEP_LINK_ACK_CHANNEL, generation) + .catch(() => undefined); + } + } + }; + const wrappedListener = ( + _event: Electron.IpcRendererEvent, + payload: unknown, + generation: unknown, + ) => { + deliver(payload, generation); + }; + + ipcRenderer.on(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + // Handshake: registering tells the main process this renderer is ready + // for pushes, and returns the link buffered while no renderer was + // (cold start) — or null. + void ipcRenderer + .invoke(IpcChannels.DEEP_LINK_SUBSCRIBE_CHANNEL) + .then((result) => { + const response = + typeof result === "object" && result !== null && "payload" in result + ? (result as { payload?: unknown; generation?: unknown }) + : { payload: result, generation: null }; + // A reply that lands after teardown is left for the next subscriber: + // main keeps the link until an active listener acknowledges it. + if (active) { + deliver(response.payload, response.generation); + } + }) + .catch(() => undefined); + return () => { + active = false; + ipcRenderer.removeListener(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + // Enqueue cleanup before a replacement listener can subscribe. Waiting + // for the old subscribe reply would unregister that replacement. + void ipcRenderer.invoke(IpcChannels.DEEP_LINK_UNSUBSCRIBE_CHANNEL).catch(() => undefined); + }; + }, onSnapShotEvent: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => { if (!isSnapShotEvent(event)) return; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index dc46278a1150..f29ce25fbb84 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -98,6 +98,7 @@ function layerElectronWindow(window: ReturnType["window"] clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, + fromWebContentsId: () => Effect.die("unexpected webContents lookup"), sendAll: () => Effect.void, destroyAll: Effect.void, syncAllAppearance: () => Effect.void, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 9d4556af42b4..5576b3a2aebf 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -118,6 +118,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, + fromWebContentsId: () => Effect.die("unexpected webContents lookup"), sendAll: (_channel, state) => Effect.sync(() => { sentStates.push(state as DesktopUpdateState); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cb374b8103de..6187c933ef91 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -278,6 +278,7 @@ function layerTest(input: { clearMain: () => Ref.set(input.mainWindow, Option.none()), prepareReveal: () => Effect.succeed(false), reveal: (window) => Effect.sync(() => input.onReveal?.(window)), + fromWebContentsId: () => Effect.die("unexpected webContents lookup"), sendAll: () => Effect.void, destroyAll: Effect.void, syncAllAppearance: (sync) => sync(input.window), @@ -398,6 +399,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n clearMain: () => Ref.set(mainWindow, Option.none()), prepareReveal: () => Effect.succeed(false), reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), + fromWebContentsId: () => Effect.die("unexpected webContents lookup"), sendAll: () => Effect.void, destroyAll: Effect.void, syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 68bab78c1c64..8b235a54356b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -227,6 +227,7 @@ function RootRouteView() { enabled={primaryEnvironmentAuthenticated} hostedStatic={authGateState.status === "hosted-static"} > + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} @@ -334,6 +335,39 @@ function FontAppearanceSync() { return null; } +// Thread deep links (t3code://threads//, also +// t3code://app/...) forwarded by the desktop main process. Lives at the root +// so a link opens the thread from any screen, including settings. A no-op on +// web and mobile, where no desktopBridge exists. +function DesktopDeepLinkNavigation() { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const readPathname = useEffectEvent(() => pathname); + + useEffect(() => { + const onDeepLink = window.desktopBridge?.onDeepLink; + if (typeof onDeepLink !== "function") { + return; + } + + const unsubscribe = onDeepLink(({ environmentId, threadId }) => { + if (readPathname() === `/${environmentId}/${threadId}`) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 61e9e7a2e3a2..eb11cf438c14 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,8 +1,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; @@ -40,6 +40,10 @@ function ChatIndexRouteView() { * end. Falls back to an add-project hero when no project exists yet. */ function IndexDraftLanding() { + const router = useRouter(); + // Read at effect time: a deep link can finish navigating before this + // index effect is flushed, and the rendered pathname would still be "/". + const isStillOnIndex = useEffectEvent(() => router.state.location.pathname === "/"); const projects = useProjects(); const threads = useThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); @@ -56,7 +60,7 @@ function IndexDraftLanding() { ); useEffect(() => { - if (mostRecentProject === null || startingRef.current) { + if (!isStillOnIndex() || mostRecentProject === null || startingRef.current) { return; } startingRef.current = true; diff --git a/docs/user/deep-links.md b/docs/user/deep-links.md new file mode 100644 index 000000000000..8f6e537d0fab --- /dev/null +++ b/docs/user/deep-links.md @@ -0,0 +1,13 @@ +# Thread Deep Links + +On macOS and Linux, the desktop app registers the `t3code://` URL scheme, so other tools can link straight to a thread: + +``` +t3code://threads// +``` + +Opening a link takes you to that thread and focuses its window once the app is ready. If you are on a setup, pairing, or connection screen, the newest link waits until you return to the app. If you are already looking at the thread, nothing changes. + +The `t3code://app//` form is also accepted. Windows installers do not register the scheme. The environment id must be a UUID — aliases like `primary` are not accepted. A link that does not match the format exactly does not navigate. The operating system may still launch the desktop app before the link is checked. + +This is handy for anything that records which thread produced a result: a notification, a log line, or a message can carry a link that drops you back into the conversation. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ad72e3e04e30..69769dca002b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1078,6 +1078,11 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ data: Schema.Uint8Array, }); +export interface DesktopThreadDeepLinkPayload { + readonly environmentId: string; + readonly threadId: string; +} + /** * A System Settings pane the app can deep-link to. The identifier crosses IPC * rather than a URL, so the renderer can only reach these known destinations. @@ -1196,6 +1201,12 @@ export interface DesktopBridge { pasteAsText?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; + /** + * Thread deep links (`t3code://threads//`, also + * `t3code://app/...`) forwarded from the OS by the main process. + * Optional: older desktop builds never emit it. + */ + onDeepLink?: (listener: (payload: DesktopThreadDeepLinkPayload) => void) => () => void; /** * Quit-confirmation hint pushes. Optional: older desktop builds never emit * them.