diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..aad6ecd14aaa 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -18,6 +18,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -224,6 +225,7 @@ const startup = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + const deepLinkRouter = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; @@ -271,6 +273,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; yield* clerk.configure; + // macOS can emit open-url before ready, and does not preserve that URL in + // argv. Register now; DesktopWindow holds it until the backend can open. + yield* deepLinkRouter.register; 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..2bd6a7c0b98b --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findDeepLinkInArgv, parseDeepLink } from "./DesktopDeepLink.ts"; + +const SCHEMES = ["t3code", "t3code-dev"] as const; + +describe("parseDeepLink", () => { + it("parses the documented thread link shape", () => { + expect(parseDeepLink("t3code://threads/env-1/thread-42", SCHEMES)).toEqual({ + kind: "thread", + environmentId: "env-1", + threadId: "thread-42", + }); + }); + + it("accepts every registered scheme, including the development one", () => { + for (const scheme of SCHEMES) { + expect(parseDeepLink(`${scheme}://threads/env/thread`, SCHEMES)).toEqual({ + kind: "thread", + environmentId: "env", + threadId: "thread", + }); + } + }); + + it("is case insensitive for the scheme and host", () => { + expect(parseDeepLink("T3CODE://THREADS/env/thread", SCHEMES)).toEqual({ + kind: "thread", + environmentId: "env", + threadId: "thread", + }); + }); + + it("percent-decodes segments", () => { + expect(parseDeepLink("t3code://threads/env%20one/thread%2Btwo", SCHEMES)).toEqual({ + kind: "thread", + environmentId: "env one", + threadId: "thread+two", + }); + }); + + it("rejects foreign schemes so other protocol handlers are never hijacked", () => { + expect(parseDeepLink("https://threads/env/thread", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3codex://threads/env/thread", SCHEMES)).toBeNull(); + }); + + it("rejects other hosts, including the renderer bundle URL", () => { + expect(parseDeepLink("t3code://app/#/env/thread", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://oauth/callback", SCHEMES)).toBeNull(); + }); + + it("requires exactly two path segments", () => { + expect(parseDeepLink("t3code://threads/env", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://threads/env/thread/extra", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://threads", SCHEMES)).toBeNull(); + }); + + // A decoded separator would let a crafted link address a different route than + // the one the two segments appear to describe. + it("rejects segments that decode back into path separators", () => { + expect(parseDeepLink("t3code://threads/env%2F..%2Fother/thread", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://threads/env/thread%3Fx%3D1", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://threads/env/thread%23frag", SCHEMES)).toBeNull(); + }); + + it("rejects blank or whitespace-padded segments", () => { + expect(parseDeepLink("t3code://threads/%20/thread", SCHEMES)).toBeNull(); + expect(parseDeepLink("t3code://threads/env/%20thread", SCHEMES)).toBeNull(); + }); + + it("rejects malformed percent-encoding instead of throwing", () => { + expect(parseDeepLink("t3code://threads/%E0%A4%A/thread", SCHEMES)).toBeNull(); + }); + + it("rejects input that is not a URL at all", () => { + expect(parseDeepLink("", SCHEMES)).toBeNull(); + expect(parseDeepLink("not a url", SCHEMES)).toBeNull(); + }); +}); + +describe("findDeepLinkInArgv", () => { + // Electron passes the full argv of the second launch, so the URL is never the + // first element in practice. + it("finds the link among unrelated arguments", () => { + expect( + findDeepLinkInArgv( + ["/path/to/T3 Code", "--allow-file-access", "t3code://threads/env/thread"], + SCHEMES, + ), + ).toBe("t3code://threads/env/thread"); + }); + + it("trims surrounding whitespace added by the shell or desktop entry", () => { + expect(findDeepLinkInArgv([" t3code://threads/env/thread "], SCHEMES)).toBe( + "t3code://threads/env/thread", + ); + }); + + it("returns the first match when several links are present", () => { + expect(findDeepLinkInArgv(["t3code://threads/a/b", "t3code://threads/c/d"], SCHEMES)).toBe( + "t3code://threads/a/b", + ); + }); + + it("returns null when no argument uses a registered scheme", () => { + expect(findDeepLinkInArgv(["/path/to/T3 Code", "--flag"], SCHEMES)).toBeNull(); + expect(findDeepLinkInArgv(["https://example.com"], SCHEMES)).toBeNull(); + expect(findDeepLinkInArgv([], SCHEMES)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/app/DesktopDeepLink.ts b/apps/desktop/src/app/DesktopDeepLink.ts new file mode 100644 index 000000000000..bd2ec6bca504 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.ts @@ -0,0 +1,103 @@ +import type { DesktopDeepLinkTarget } from "@t3tools/contracts"; + +// Parsing for the `t3code://` deep links that address an existing thread. +// +// The external contract is the same one the mobile app and widgets already use: +// +// t3code://threads// +// +// which maps onto the renderer's typed route `/$environmentId/$threadId`. Only +// the external shape is stable; the renderer route stays an implementation +// detail, so callers outside the app never depend on the hash history layout. +// +// Parsing lives here as plain functions (no Effect) so the URL edge cases can be +// unit tested without an Electron or Effect runtime, mirroring wslPathParsing. + +export const DEEP_LINK_THREAD_HOST = "threads"; + +// Re-exported so main-process modules do not each reach into the contracts +// package for what is, from their side, this module's return type. +export type DeepLinkTarget = DesktopDeepLinkTarget; + +// Path segments are percent-decoded by `URL`, but a decoded segment can still +// be empty, contain a path separator, or be pure whitespace when the link was +// hand-written or truncated by a chat client. Anything that is not a single +// clean segment is rejected rather than normalized: a wrong-but-plausible id +// would navigate to someone else's thread, which is worse than not navigating. +function isCleanSegment(value: string): boolean { + if (value.length === 0 || value.trim().length !== value.length) return false; + return !/[/\\?#]/.test(value); +} + +/** + * Parses a deep link URL into a navigation target. + * + * Returns `null` for anything that is not a well-formed thread link, including + * links using a different scheme, so callers can fall back to simply revealing + * the window. + */ +export function parseDeepLink(rawUrl: string, schemes: readonly string[]): DeepLinkTarget | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + // `URL.protocol` keeps the trailing colon. + const scheme = url.protocol.replace(/:$/, "").toLowerCase(); + if (!schemes.some((candidate) => candidate.toLowerCase() === scheme)) { + return null; + } + + // `t3code://threads//` puts `threads` in the host position, not + // in the path — the authority component absorbs the first segment. + if (url.hostname.toLowerCase() !== DEEP_LINK_THREAD_HOST) { + return null; + } + + const segments = url.pathname.split("/").filter((segment) => segment.length > 0); + if (segments.length !== 2) { + return null; + } + + let environmentId: string; + let threadId: string; + try { + environmentId = decodeURIComponent(segments[0] ?? ""); + threadId = decodeURIComponent(segments[1] ?? ""); + } catch { + // Malformed percent-encoding. + return null; + } + + if (!isCleanSegment(environmentId) || !isCleanSegment(threadId)) { + return null; + } + + return { kind: "thread", environmentId, threadId }; +} + +/** + * Extracts the first deep link from a process argv vector. + * + * Windows and Linux deliver protocol URLs as a command-line argument — both on + * first launch and, for an already-running app, through Electron's + * `second-instance` event. macOS instead emits `open-url`, so this is not used + * there. + */ +export function findDeepLinkInArgv( + argv: readonly string[], + schemes: readonly string[], +): string | null { + for (const arg of argv) { + if (typeof arg !== "string") continue; + const trimmed = arg.trim(); + if (trimmed.length === 0) continue; + if (!schemes.some((scheme) => trimmed.toLowerCase().startsWith(`${scheme.toLowerCase()}://`))) { + continue; + } + return trimmed; + } + return null; +} diff --git a/apps/desktop/src/app/DesktopDeepLinkBuffer.test.ts b/apps/desktop/src/app/DesktopDeepLinkBuffer.test.ts new file mode 100644 index 000000000000..771fe2fba304 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinkBuffer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { decodeDesktopDeepLinkTarget, makeDesktopDeepLinkBuffer } from "./DesktopDeepLinkBuffer.ts"; + +const target = { + kind: "thread" as const, + environmentId: "environment-1", + threadId: "thread-1", +}; + +describe("DesktopDeepLinkBuffer", () => { + it("replays the latest target received before the renderer subscribes", () => { + const buffer = makeDesktopDeepLinkBuffer(); + buffer.push({ ...target, threadId: "stale" }); + buffer.push(target); + const listener = vi.fn(); + + buffer.subscribe(listener); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(target); + }); + + it("delivers later targets directly and stops after unsubscribe", () => { + const buffer = makeDesktopDeepLinkBuffer(); + const listener = vi.fn(); + const unsubscribe = buffer.subscribe(listener); + + buffer.push(target); + unsubscribe(); + buffer.push({ ...target, threadId: "later" }); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it("rejects malformed renderer-bound targets", () => { + expect(decodeDesktopDeepLinkTarget(target)).toEqual(target); + expect(decodeDesktopDeepLinkTarget(null)).toBeNull(); + expect(decodeDesktopDeepLinkTarget({ ...target, threadId: 1 })).toBeNull(); + expect(decodeDesktopDeepLinkTarget({ ...target, kind: "project" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinkBuffer.ts b/apps/desktop/src/app/DesktopDeepLinkBuffer.ts new file mode 100644 index 000000000000..c95ecb70c4f8 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinkBuffer.ts @@ -0,0 +1,43 @@ +import type { DesktopDeepLinkTarget } from "@t3tools/contracts"; + +export function decodeDesktopDeepLinkTarget(value: unknown): DesktopDeepLinkTarget | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Record; + if ( + candidate.kind !== "thread" || + typeof candidate.environmentId !== "string" || + typeof candidate.threadId !== "string" + ) { + return null; + } + return { + kind: "thread", + environmentId: candidate.environmentId, + threadId: candidate.threadId, + }; +} + +export function makeDesktopDeepLinkBuffer() { + const listeners = new Set<(target: DesktopDeepLinkTarget) => void>(); + let pending: DesktopDeepLinkTarget | null = null; + + const push = (target: DesktopDeepLinkTarget) => { + if (listeners.size === 0) { + pending = target; + return; + } + for (const listener of listeners) listener(target); + }; + + const subscribe = (listener: (target: DesktopDeepLinkTarget) => void) => { + listeners.add(listener); + if (pending !== null) { + const target = pending; + pending = null; + listener(target); + } + return () => listeners.delete(listener); + }; + + return { push, subscribe }; +} diff --git a/apps/desktop/src/app/DesktopDeepLinkRouter.ts b/apps/desktop/src/app/DesktopDeepLinkRouter.ts new file mode 100644 index 000000000000..6a6eb259aa17 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinkRouter.ts @@ -0,0 +1,92 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopDeepLink from "./DesktopDeepLink.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// Delivers `t3code://` links to the renderer. +// +// The two platforms hand the URL over differently: +// * macOS emits `open-url` — the URL never appears in argv. +// * Windows/Linux pass it as a command-line argument, both on first launch +// and, for an already-running app, via `second-instance`. +// +// Both paths funnel into the same DesktopWindow.dispatchDeepLink, which owns +// window creation, the wait for the first renderer load, and the hold-until- +// backend-ready behaviour. + +const { logInfo } = makeComponentLogger("desktop-deep-link-router"); + +export class DesktopDeepLinkRouter extends Context.Service< + DesktopDeepLinkRouter, + { + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinkRouter") {} + +export const make = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const schemes = [ElectronProtocol.getDesktopScheme(environment.isDevelopment)]; + + const handle = (rawUrl: string, source: "open-url" | "second-instance" | "launch-argv") => { + const target = DesktopDeepLink.parseDeepLink(rawUrl, schemes); + if (target === null) { + // Not ours to act on: OAuth callbacks and the renderer bundle URL also + // use this scheme, and they are handled elsewhere. + return; + } + void runPromise( + Effect.gen(function* () { + yield* logInfo("received deep link", { source, kind: target.kind }); + yield* desktopWindow.dispatchDeepLink(target); + }).pipe( + // A malformed link must never take down the app. + Effect.catchCause((cause) => + Effect.logWarning("failed to dispatch deep link", { source, cause }), + ), + ), + ); + }; + + const register = Effect.gen(function* () { + // Typed structurally so this module keeps talking to Electron only through + // the ElectronApp service, as the rest of the app does. + yield* electronApp.on<[{ preventDefault: () => void }, string]>("open-url", (event, url) => { + event.preventDefault(); + handle(url, "open-url"); + }); + + // A second `second-instance` listener alongside the one that reveals the + // window: Electron invokes every registered listener, so window reveal and + // link routing stay owned by their respective modules. + yield* electronApp.on<[unknown, readonly string[], string]>( + "second-instance", + (_event, argv) => { + const url = DesktopDeepLink.findDeepLinkInArgv(argv ?? [], schemes); + if (url !== null) handle(url, "second-instance"); + }, + ); + + // Windows/Linux cold start: the link that launched the app is already in + // our own argv, and no event will ever be emitted for it. + const launchUrl = DesktopDeepLink.findDeepLinkInArgv(process.argv, schemes); + if (launchUrl !== null) { + handle(launchUrl, "launch-argv"); + } + }).pipe(Effect.withSpan("desktop.deepLinkRouter.register")); + + return DesktopDeepLinkRouter.of({ register }); +}); + +export const layer = Layer.effect(DesktopDeepLinkRouter, make); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af6..541cb9f2580e 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -93,6 +93,7 @@ function makeDesktopWindowLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, dispatchMenuAction: () => Effect.void, + dispatchDeepLink: () => Effect.void, zoomMain: () => Effect.void, syncAppearance: Effect.void, }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbee..c9bc52c4c83c 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,6 +91,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + dispatchDeepLink: () => Effect.die("unexpected deep link"), zoomMain: () => Effect.die("unexpected zoom"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 180e02810801..8d50fc74ec1d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -6,6 +6,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const DEEP_LINK_CHANNEL = "desktop:deep-link"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..98266a00f818 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopDeepLinkRouter from "./app/DesktopDeepLinkRouter.ts"; import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; @@ -186,6 +187,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopDeepLinkRouter.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ee03141f2d82..0154783de5eb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,10 +7,20 @@ import type { import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; +import { + decodeDesktopDeepLinkTarget, + makeDesktopDeepLinkBuffer, +} from "./app/DesktopDeepLinkBuffer.ts"; import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); +const deepLinkBuffer = makeDesktopDeepLinkBuffer(); +ipcRenderer.on(IpcChannels.DEEP_LINK_CHANNEL, (_event, value: unknown) => { + const target = decodeDesktopDeepLinkTarget(value); + if (target !== null) deepLinkBuffer.push(target); +}); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -132,6 +142,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); }; }, + onDeepLink: (listener) => deepLinkBuffer.subscribe(listener), getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d3..ad7b05d614bf 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -81,6 +81,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + dispatchDeepLink: () => Effect.void, zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), syncAppearance: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..e73e2781f3cd 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -43,7 +43,11 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + DEEP_LINK_CHANNEL, + MENU_ACTION_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -394,6 +398,58 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it.effect("holds a cold-start deep link until the backend can create a window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); + const target = { + kind: "thread" as const, + environmentId: "environment-1", + threadId: "thread-1", + }; + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchDeepLink(target); + assert.equal(yield* Ref.get(createCount), 0); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.send.mock.calls, [[DEEP_LINK_CHANNEL, target]]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("delivers a deep link that races backend readiness exactly once", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); + const target = { + kind: "thread" as const, + environmentId: "environment-1", + threadId: "thread-1", + }; + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* Effect.all( + [ + desktopWindow.dispatchDeepLink(target), + desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), + ], + { concurrency: "unbounded" }, + ); + + assert.deepEqual(fakeWindow.send.mock.calls, [[DEEP_LINK_CHANNEL, target]]); + }).pipe(Effect.provide(layer)); + }), + ); + it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..2a2941a216d0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -14,11 +14,13 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import type { DeepLinkTarget } from "../app/DesktopDeepLink.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { + DEEP_LINK_CHANNEL, MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, @@ -100,6 +102,11 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Routes an already-parsed deep link to the renderer. Unlike a menu action, + // this can arrive before the backend is ready (the link is what launched the + // app), so an undeliverable target is held and replayed by + // handleBackendReady instead of being dropped. + readonly dispatchDeepLink: (target: DeepLinkTarget) => Effect.Effect; // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` // menu roles act on whichever webContents has keyboard focus, so with an // embedded preview WebContentsView (or DevTools) focused they zoom the @@ -280,6 +287,13 @@ export const make = Effect.gen(function* () { // createMainIfBackendReady, which gates the post-readiness window // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); + // A deep link can be what launches the app, so it may arrive long before the + // backend is ready and before any window exists. Holding the most recent + // target here (rather than dropping it, as menu actions do) is what makes + // "click a link while the app is closed" land on the right thread; it is + // replayed once by handleBackendReady. Only the newest target is kept — if + // several links arrive during startup, the last one is what the user meant. + const pendingDeepLinkRef = yield* Ref.make>(Option.none()); // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); @@ -833,6 +847,54 @@ export const make = Effect.gen(function* () { Effect.withSpan("desktop.window.showConnectingSplash"), ); + const sendDeepLink = Effect.fn("desktop.window.sendDeepLink")(function* ( + targetWindow: Electron.BrowserWindow, + target: DeepLinkTarget, + ) { + yield* Effect.annotateCurrentSpan({ environmentId: target.environmentId }); + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(DEEP_LINK_CHANNEL, target); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + // On a cold start the renderer is still loading and would miss an immediate + // send, so wait for the first load to finish before handing the link over. + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }); + + const flushPendingDeepLink = Effect.fn("desktop.window.flushPendingDeepLink")(function* () { + const pending = yield* Ref.getAndSet(pendingDeepLinkRef, Option.none()); + if (Option.isNone(pending)) return; + const existingWindow = yield* focusedMainWindow; + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; + yield* sendDeepLink(targetWindow, pending.value); + }); + + const deliverDeepLink = Effect.fn("desktop.window.deliverDeepLink")(function* ( + target: DeepLinkTarget, + ) { + const existingWindow = yield* focusedMainWindow; + if (Option.isSome(existingWindow)) { + yield* sendDeepLink(existingWindow.value, target); + return; + } + + // Store first, then re-check readiness. If backend readiness races this + // write, either handleBackendReady or this flush wins the atomic take. + yield* Ref.set(pendingDeepLinkRef, Option.some(target)); + if (yield* Ref.get(backendReadyRef)) { + yield* flushPendingDeepLink(); + return; + } + yield* logWindowInfo("deep link held until backend is ready", { kind: target.kind }); + }); + return DesktopWindow.of({ createMain, ensureMain, @@ -864,6 +926,10 @@ export const make = Effect.gen(function* () { yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); yield* createMainIfBackendReady; + + // Replay a link that arrived before the backend came up. Taken (not read) + // so a later backend restart cannot deliver the same link a second time. + yield* flushPendingDeepLink(); }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), @@ -892,6 +958,7 @@ export const make = Effect.gen(function* () { send(); }), + dispatchDeepLink: deliverDeepLink, zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { yield* Effect.annotateCurrentSpan({ direction }); const window = yield* focusedMainWindow; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba76679689..ae74b5a8722f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -208,6 +208,27 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }; }, [navigate, pathname]); + useEffect(() => { + const onDeepLink = window.desktopBridge?.onDeepLink; + if (typeof onDeepLink !== "function") { + return; + } + + const unsubscribe = onDeepLink((target) => { + if (target.kind !== "thread") { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: target.environmentId, threadId: target.threadId }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); + return ( diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93f074f7be56..ad840a6ba364 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1061,6 +1061,18 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +/** + * A `t3code://` deep link that has already been validated by the main process. + * + * Discriminated so future link kinds (for example opening a dialog) can be added + * without the renderer having to re-parse raw URLs. + */ +export type DesktopDeepLinkTarget = { + readonly kind: "thread"; + readonly environmentId: string; + readonly threadId: string; +}; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; /** @@ -1135,6 +1147,7 @@ export interface DesktopBridge { * desktop builds never emit it. */ onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onDeepLink: (listener: (target: DesktopDeepLinkTarget) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise;