From 59f4ecabd64267f8dff6c1877765a9e55d768a99 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 18 Aug 2026 00:23:55 +0100 Subject: [PATCH 1/5] feat(desktop): open threads from external links --- apps/desktop/src/app/DesktopDeepLink.test.ts | 41 ++++++ apps/desktop/src/app/DesktopDeepLink.ts | 56 ++++++++ .../src/app/DesktopDeepLinkRouter.test.ts | 121 ++++++++++++++++++ apps/desktop/src/app/DesktopDeepLinkRouter.ts | 58 +++++++++ apps/desktop/src/app/DesktopLifecycle.test.ts | 11 ++ apps/desktop/src/app/DesktopLifecycle.ts | 15 ++- .../src/backend/DesktopBackendPool.test.ts | 1 + apps/desktop/src/main.ts | 2 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 54 ++++++++ apps/desktop/src/window/DesktopWindow.ts | 22 +++- docs/README.md | 1 + docs/user/desktop-links.md | 23 ++++ 13 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/DesktopDeepLink.test.ts create mode 100644 apps/desktop/src/app/DesktopDeepLink.ts create mode 100644 apps/desktop/src/app/DesktopDeepLinkRouter.test.ts create mode 100644 apps/desktop/src/app/DesktopDeepLinkRouter.ts create mode 100644 docs/user/desktop-links.md diff --git a/apps/desktop/src/app/DesktopDeepLink.test.ts b/apps/desktop/src/app/DesktopDeepLink.test.ts new file mode 100644 index 000000000000..103de74b46d8 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.test.ts @@ -0,0 +1,41 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { buildDesktopThreadLink, parseDesktopThreadLink } from "./DesktopDeepLink.ts"; + +describe("DesktopDeepLink", () => { + it("builds the desktop hash route for an externally supplied thread", () => { + assert.equal( + buildDesktopThreadLink({ + isDevelopment: false, + environmentId: "environment-123", + threadId: "thread-456", + }), + "t3code://app/#/environment-123/thread-456", + ); + }); + + it("parses only a scoped thread route from the desktop scheme", () => { + assert.deepEqual( + parseDesktopThreadLink({ + isDevelopment: false, + value: "t3code://app/#/environment-123/thread-456", + }), + { + environmentId: "environment-123", + threadId: "thread-456", + }, + ); + assert.isNull( + parseDesktopThreadLink({ + isDevelopment: false, + value: "t3code://app/CLERK-ROUTER/VIRTUAL/sign-in", + }), + ); + assert.isNull( + parseDesktopThreadLink({ + isDevelopment: false, + value: "t3code://other/#/environment-123/thread-456", + }), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopDeepLink.ts b/apps/desktop/src/app/DesktopDeepLink.ts new file mode 100644 index 000000000000..631a453925d2 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLink.ts @@ -0,0 +1,56 @@ +import { DESKTOP_HOST, getDesktopScheme } from "../electron/ElectronProtocol.ts"; + +export interface DesktopThreadLink { + readonly environmentId: string; + readonly threadId: string; +} + +export function buildDesktopThreadLink(input: { + readonly isDevelopment: boolean; + readonly environmentId: string; + readonly threadId: string; +}): string { + const scheme = getDesktopScheme(input.isDevelopment); + return `${scheme}://${DESKTOP_HOST}/#/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; +} + +function decodeThreadLinkSegment(value: string): string | null { + try { + const decoded = decodeURIComponent(value); + return decoded.trim() === decoded && decoded.length > 0 && !decoded.includes("/") + ? decoded + : null; + } catch { + return null; + } +} + +export function parseDesktopThreadLink(input: { + readonly isDevelopment: boolean; + readonly value: string; +}): DesktopThreadLink | null { + let url: URL; + try { + url = new URL(input.value); + } catch { + return null; + } + + if ( + url.protocol !== `${getDesktopScheme(input.isDevelopment)}:` || + url.host !== DESKTOP_HOST || + url.pathname !== "/" || + url.search.length > 0 + ) { + return null; + } + + const parts = url.hash.slice(1).split("/"); + if (parts.length !== 3 || parts[0] !== "") { + return null; + } + + const environmentId = decodeThreadLinkSegment(parts[1] ?? ""); + const threadId = decodeThreadLinkSegment(parts[2] ?? ""); + return environmentId === null || threadId === null ? null : { environmentId, threadId }; +} diff --git a/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts b/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts new file mode 100644 index 000000000000..1d4dc22c102a --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts @@ -0,0 +1,121 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts"; + +function makeRouterLayer(input: { + readonly listeners: Map) => void>; + readonly openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }>; +}) { + const electronApp = { + on: (eventName: string, listener: (...args: Array) => void) => + Effect.sync(() => { + input.listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const desktopWindow = { + openThread: (thread: { readonly environmentId: string; readonly threadId: string }) => + Effect.sync(() => { + input.openedThreads.push(thread); + }), + } as unknown as DesktopWindow.DesktopWindow["Service"]; + const environment = { + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]; + + return DesktopDeepLinkRouter.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ElectronApp.ElectronApp, electronApp), + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(DesktopWindow.DesktopWindow, desktopWindow), + ), + ), + ); +} + +describe("DesktopDeepLinkRouter", () => { + it.effect("opens a thread link passed when the desktop app starts", () => + Effect.gen(function* () { + const listeners = new Map) => void>(); + const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> = + []; + const layer = makeRouterLayer({ listeners, openedThreads }); + const originalArgv = process.argv; + process.argv = ["T3 Code", "t3code://app/#/environment-123/thread-456"]; + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.argv = originalArgv; + }), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; + yield* router.configure; + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(openedThreads, [ + { environmentId: "environment-123", threadId: "thread-456" }, + ]); + }), + ).pipe(Effect.provide(layer)); + }), + ); + + it.effect("forwards a second-instance thread link to the desktop window", () => + Effect.gen(function* () { + const listeners = new Map) => void>(); + const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> = + []; + const layer = makeRouterLayer({ listeners, openedThreads }); + + yield* Effect.scoped( + Effect.gen(function* () { + const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; + yield* router.configure; + const secondInstance = listeners.get("second-instance"); + if (!secondInstance) { + return yield* Effect.die("second-instance listener was not registered"); + } + + secondInstance({}, ["T3 Code", "t3code://app/#/environment-123/thread-456"]); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(openedThreads, [ + { environmentId: "environment-123", threadId: "thread-456" }, + ]); + }), + ).pipe(Effect.provide(layer)); + }), + ); + + it.effect("forwards a macOS URL activation to the desktop window", () => + Effect.gen(function* () { + const listeners = new Map) => void>(); + const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> = + []; + const layer = makeRouterLayer({ listeners, openedThreads }); + + yield* Effect.scoped( + Effect.gen(function* () { + const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; + yield* router.configure; + const openUrl = listeners.get("open-url"); + if (!openUrl) { + return yield* Effect.die("open-url listener was not registered"); + } + + openUrl({}, "t3code://app/#/environment-123/thread-456"); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(openedThreads, [ + { environmentId: "environment-123", threadId: "thread-456" }, + ]); + }), + ).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinkRouter.ts b/apps/desktop/src/app/DesktopDeepLinkRouter.ts new file mode 100644 index 000000000000..816b2b3a3d5f --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinkRouter.ts @@ -0,0 +1,58 @@ +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 DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; +import { parseDesktopThreadLink } from "./DesktopDeepLink.ts"; + +const { logWarning } = makeComponentLogger("desktop-deep-link"); + +export class DesktopDeepLinkRouter extends Context.Service< + DesktopDeepLinkRouter, + { + readonly configure: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinkRouter") {} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const openFirstThreadLink = (values: ReadonlyArray) => { + const thread = values + .map((value) => parseDesktopThreadLink({ isDevelopment: environment.isDevelopment, value })) + .find((value) => value !== null); + if (thread === undefined || thread === null) return; + + void runPromise( + desktopWindow.openThread(thread).pipe( + Effect.catch((error) => + logWarning("failed to open thread deep link", { + message: error.message, + }), + ), + ), + ); + }; + + return DesktopDeepLinkRouter.of({ + configure: Effect.gen(function* () { + openFirstThreadLink(process.argv); + yield* electronApp.on("second-instance", (_event, commandLine: string[]) => { + openFirstThreadLink(commandLine); + }); + yield* electronApp.on("open-url", (_event, url: string) => { + openFirstThreadLink([url]); + }); + }).pipe(Effect.withSpan("desktop.deepLink.configure")), + }); +}); + +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 45e1c82460c8..aa738bea6401 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -8,6 +8,7 @@ import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -17,6 +18,7 @@ describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { const appListeners = new Map void>(); + let deepLinkRouterConfigurations = 0; const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), @@ -71,6 +73,7 @@ describe("DesktopLifecycle", () => { createMain: Effect.die("unexpected window creation"), ensureMain: Effect.die("unexpected window creation"), revealOrCreateMain: Effect.die("unexpected window creation"), + openThread: () => Effect.void, activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, @@ -91,6 +94,13 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(electronAppLayer), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge( + Layer.succeed(DesktopDeepLinkRouter.DesktopDeepLinkRouter, { + configure: Effect.sync(() => { + deepLinkRouterConfigurations += 1; + }), + }), + ), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), @@ -100,6 +110,7 @@ describe("DesktopLifecycle", () => { Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; yield* lifecycle.register; + assert.equal(deepLinkRouterConfigurations, 1); appListeners.get("before-quit-for-update")?.(); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ab03d18f38d4..e0ced0d413da 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -7,6 +7,7 @@ import * as Scope from "effect/Scope"; import type * as Electron from "electron"; +import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; @@ -35,6 +36,10 @@ export type DesktopLifecycleRuntimeServices = | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; +type DesktopLifecycleRegistrationServices = + | DesktopDeepLinkRouter.DesktopDeepLinkRouter + | DesktopLifecycleRuntimeServices; + /** * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme */ @@ -44,7 +49,11 @@ export class DesktopLifecycle extends Context.Service< readonly relaunch: ( reason: string, ) => Effect.Effect; - readonly register: Effect.Effect; + readonly register: Effect.Effect< + void, + never, + Scope.Scope | DesktopLifecycleRegistrationServices + >; } >()("@t3tools/desktop/app/DesktopLifecycle") {} @@ -169,14 +178,16 @@ export const make = DesktopLifecycle.of({ ); }), register: Effect.gen(function* () { + const deepLinkRouter = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); + const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; let updaterQuitAllowed = false; + yield* deepLinkRouter.configure; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbee..c6fc82aab534 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -84,6 +84,7 @@ function makePoolLayer( createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), revealOrCreateMain: Effect.die("unexpected window reveal"), + openThread: () => Effect.die("unexpected thread navigation"), activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74d..65a8dd2a8509 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopDeepLinkRouter from "./app/DesktopDeepLinkRouter.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -182,6 +183,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, + DesktopDeepLinkRouter.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 09c28776342c..1063d0aa8ce5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -73,6 +73,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + openThread: () => Effect.die("unexpected thread navigation"), activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0e..d3a0395c5de8 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -438,6 +438,60 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens a pending external thread when the backend becomes ready", () => + 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, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.openThread({ + environmentId: "environment-123", + threadId: "thread-456", + }); + + assert.equal(yield* Ref.get(createCount), 0); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/#/environment-123/thread-456"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates the open desktop window to an external thread", () => + 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, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* desktopWindow.openThread({ + environmentId: "environment-123", + threadId: "thread-456", + }); + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/"], + ["t3code-dev://app/#/environment-123/thread-456"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448fe..10cbb46c791e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; +import { buildDesktopThreadLink, type DesktopThreadLink } from "../app/DesktopDeepLink.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -69,6 +70,7 @@ export class DesktopWindow extends Context.Service< readonly createMain: Effect.Effect; readonly ensureMain: Effect.Effect; readonly revealOrCreateMain: Effect.Effect; + readonly openThread: (thread: DesktopThreadLink) => Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only @@ -274,6 +276,7 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + let pendingApplicationUrl: string | null = null; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -306,7 +309,7 @@ export const make = Effect.gen(function* () { DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = getDesktopUrl(environment.isDevelopment); + const applicationUrl = pendingApplicationUrl ?? getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -739,6 +742,22 @@ export const make = Effect.gen(function* () { yield* createMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + const openThread = Effect.fn("desktop.window.openThread")(function* (thread: DesktopThreadLink) { + const applicationUrl = buildDesktopThreadLink({ + isDevelopment: environment.isDevelopment, + environmentId: thread.environmentId, + threadId: thread.threadId, + }); + pendingApplicationUrl = applicationUrl; + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + void existingWindow.value.loadURL(applicationUrl).catch(() => undefined); + yield* electronWindow.reveal(existingWindow.value); + return; + } + yield* createMainIfBackendReady; + }); + const showConnectingSplash = Effect.gen(function* () { // Only when nothing is shown yet: no real window, no existing splash. const existingSplash = yield* Ref.get(splashWindowRef); @@ -789,6 +808,7 @@ export const make = Effect.gen(function* () { createMain, ensureMain, revealOrCreateMain, + openThread, activate: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..2e6127863952 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Open a desktop thread from another tool](./user/desktop-links.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) diff --git a/docs/user/desktop-links.md b/docs/user/desktop-links.md new file mode 100644 index 000000000000..ac2d3c491ac6 --- /dev/null +++ b/docs/user/desktop-links.md @@ -0,0 +1,23 @@ +# Open a desktop thread from another tool + +The T3 Code desktop app can open a specific thread from a link produced by another tool, such as a +control surface, editor integration, notification, or script. + +Use this format with the environment and thread IDs from T3 Code: + +```text +t3code://app/#// +``` + +For example: + +```text +t3code://app/#/environment-123/thread-456 +``` + +Opening the link brings the desktop app forward and navigates it to that thread. The link contains +only identifiers, never a pairing token or bearer token. It can therefore open threads only in an +environment that is already configured and authenticated in that desktop app. + +Local desktop development builds use the separate `t3code-dev://app/#/...` scheme so they do not +intercept links intended for a released desktop app. From f328ae1a62407c0ed3decc6a199ece9894d02658 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 18 Aug 2026 10:31:58 +0100 Subject: [PATCH 2/5] fix(desktop): coalesce deep-link window creation --- apps/desktop/src/window/DesktopWindow.test.ts | 106 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 43 ++++--- 2 files changed, 134 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index d3a0395c5de8..636fee83093d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -185,6 +185,8 @@ function makeTestLayer(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly beforeWindowCreate?: Effect.Effect; + readonly exposeCreatedWindowBeforeMain?: boolean; readonly openedExternalUrls?: unknown[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; @@ -221,17 +223,27 @@ function makeTestLayer(input: { applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const currentMainOrFirst = input.exposeCreatedWindowBeforeMain + ? Effect.gen(function* () { + const main = yield* Ref.get(input.mainWindow); + if (Option.isSome(main) || (yield* Ref.get(input.createCount)) === 0) { + return main; + } + return Option.some(input.window); + }) + : Ref.get(input.mainWindow); const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { input.createdWindowOptions?.push(options); }).pipe( Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.andThen(input.beforeWindowCreate ?? Effect.void), Effect.as(input.window), ), main: Ref.get(input.mainWindow), - currentMainOrFirst: Ref.get(input.mainWindow), - focusedMainOrFirst: Ref.get(input.mainWindow), + currentMainOrFirst, + focusedMainOrFirst: currentMainOrFirst, setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), clearMain: () => Ref.set(input.mainWindow, Option.none()), reveal: () => Effect.void, @@ -492,6 +504,96 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens the normal app URL after a deep-link window is closed", () => + 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, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* desktopWindow.openThread({ + environmentId: "environment-123", + threadId: "thread-456", + }); + + const closed = fakeWindow.windowListeners.get("closed"); + if (!closed) { + return yield* Effect.die("closed listener was not registered"); + } + closed(); + yield* Effect.promise(() => Promise.resolve()); + + yield* desktopWindow.activate; + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/"], + ["t3code-dev://app/#/environment-123/thread-456"], + ["t3code-dev://app/"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("coalesces concurrent cold-start links on the latest thread", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const firstCreationStarted = yield* Deferred.make(); + const allowFirstCreation = yield* Deferred.make(); + const isFirstCreation = yield* Ref.make(true); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + exposeCreatedWindowBeforeMain: true, + beforeWindowCreate: Effect.gen(function* () { + if (!(yield* Ref.getAndSet(isFirstCreation, false))) return; + yield* Deferred.succeed(firstCreationStarted, undefined); + yield* Deferred.await(allowFirstCreation); + }), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.openThread({ + environmentId: "environment-123", + threadId: "thread-456", + }); + const readyFiber = yield* desktopWindow + .handleBackendReady(new URL("http://127.0.0.1:3773")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstCreationStarted); + + const newerLinkFiber = yield* desktopWindow + .openThread({ + environmentId: "environment-789", + threadId: "thread-abc", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + assert.equal(yield* Ref.get(createCount), 1); + + yield* Deferred.succeed(allowFirstCreation, undefined); + yield* Fiber.join(readyFiber); + yield* Fiber.join(newerLinkFiber); + + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/#/environment-789/thread-abc"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10cbb46c791e..a01bc3d331b9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -269,6 +270,7 @@ 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); + const mainWindowCreation = yield* Semaphore.make(1); // 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()); @@ -276,6 +278,9 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + // A cold-start deep link is consumed by the window that loads it. A newer + // link arriving during creation remains pending and navigates that window + // once it is registered as main. let pendingApplicationUrl: string | null = null; const dismissConnectingSplash = Effect.gen(function* () { @@ -309,7 +314,7 @@ export const make = Effect.gen(function* () { DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = pendingApplicationUrl ?? getDesktopUrl(environment.isDevelopment); + let applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -356,6 +361,8 @@ export const make = Effect.gen(function* () { webviewTag: true, }, }); + applicationUrl = pendingApplicationUrl ?? applicationUrl; + pendingApplicationUrl = null; if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -720,13 +727,17 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.createMain")); - const ensureMain = Effect.gen(function* () { + const ensureMainUnserialized = Effect.gen(function* () { const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { return existingWindow.value; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }); + + const ensureMain = mainWindowCreation + .withPermits(1)(ensureMainUnserialized) + .pipe(Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -737,9 +748,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) return; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) return; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const openThread = Effect.fn("desktop.window.openThread")(function* (thread: DesktopThreadLink) { @@ -749,13 +758,21 @@ export const make = Effect.gen(function* () { threadId: thread.threadId, }); pendingApplicationUrl = applicationUrl; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) { - void existingWindow.value.loadURL(applicationUrl).catch(() => undefined); - yield* electronWindow.reveal(existingWindow.value); - return; - } - yield* createMainIfBackendReady; + yield* mainWindowCreation.withPermits(1)( + Effect.gen(function* () { + const existingWindow = yield* currentMainWindow; + const window = Option.isSome(existingWindow) + ? existingWindow.value + : (yield* Ref.get(backendReadyRef)) + ? yield* createMain + : null; + if (window === null || pendingApplicationUrl !== applicationUrl) return; + + pendingApplicationUrl = null; + void window.loadURL(applicationUrl).catch(() => undefined); + yield* electronWindow.reveal(window); + }), + ); }); const showConnectingSplash = Effect.gen(function* () { From 95861f69c52cab1f923cd39a793f80c046b94df0 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 18 Aug 2026 10:42:18 +0100 Subject: [PATCH 3/5] fix(desktop): buffer macOS deep links --- .../src/app/DesktopDeepLinkRouter.test.ts | 58 +++++++++++++++++-- apps/desktop/src/app/DesktopDeepLinkRouter.ts | 8 ++- .../src/app/DesktopPreReadyPlatform.test.ts | 27 ++++++++- .../src/app/DesktopPreReadyPlatform.ts | 51 ++++++++++++++-- apps/desktop/src/main.ts | 7 ++- 5 files changed, 138 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts b/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts index 1d4dc22c102a..7a6896a23855 100644 --- a/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts +++ b/apps/desktop/src/app/DesktopDeepLinkRouter.test.ts @@ -6,10 +6,12 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; function makeRouterLayer(input: { readonly listeners: Map) => void>; readonly openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }>; + readonly earlyOpenUrls?: DesktopPreReadyPlatform.EarlyOpenUrlBuffer; }) { const electronApp = { on: (eventName: string, listener: (...args: Array) => void) => @@ -33,6 +35,12 @@ function makeRouterLayer(input: { Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(DesktopWindow.DesktopWindow, desktopWindow), + Layer.succeed( + DesktopPreReadyPlatform.DesktopPreReadyOpenUrls, + input.earlyOpenUrls ?? { + setHandler: () => {}, + }, + ), ), ), ); @@ -95,21 +103,63 @@ describe("DesktopDeepLinkRouter", () => { it.effect("forwards a macOS URL activation to the desktop window", () => Effect.gen(function* () { + let openUrlListener: ((event: unknown, url: string) => void) | undefined; + const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({ + platform: "darwin", + electronApp: { + on: (_eventName, listener) => { + openUrlListener = listener; + }, + }, + }); const listeners = new Map) => void>(); const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> = []; - const layer = makeRouterLayer({ listeners, openedThreads }); + const layer = makeRouterLayer({ listeners, openedThreads, earlyOpenUrls }); yield* Effect.scoped( Effect.gen(function* () { const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; yield* router.configure; - const openUrl = listeners.get("open-url"); - if (!openUrl) { + if (!openUrlListener) { return yield* Effect.die("open-url listener was not registered"); } - openUrl({}, "t3code://app/#/environment-123/thread-456"); + openUrlListener({}, "t3code://app/#/environment-123/thread-456"); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(openedThreads, [ + { environmentId: "environment-123", threadId: "thread-456" }, + ]); + }), + ).pipe(Effect.provide(layer)); + }), + ); + + it.effect("routes a macOS URL captured before lifecycle registration", () => + Effect.gen(function* () { + let openUrlListener: ((event: unknown, url: string) => void) | undefined; + const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({ + platform: "darwin", + electronApp: { + on: (_eventName, listener) => { + openUrlListener = listener; + }, + }, + }); + const listeners = new Map) => void>(); + const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> = + []; + const layer = makeRouterLayer({ listeners, openedThreads, earlyOpenUrls }); + + if (!openUrlListener) { + return yield* Effect.die("open-url listener was not registered"); + } + openUrlListener({}, "t3code://app/#/environment-123/thread-456"); + + yield* Effect.scoped( + Effect.gen(function* () { + const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter; + yield* router.configure; yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(openedThreads, [ { environmentId: "environment-123", threadId: "thread-456" }, diff --git a/apps/desktop/src/app/DesktopDeepLinkRouter.ts b/apps/desktop/src/app/DesktopDeepLinkRouter.ts index 816b2b3a3d5f..26758b5d4a9b 100644 --- a/apps/desktop/src/app/DesktopDeepLinkRouter.ts +++ b/apps/desktop/src/app/DesktopDeepLinkRouter.ts @@ -8,6 +8,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import { parseDesktopThreadLink } from "./DesktopDeepLink.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; const { logWarning } = makeComponentLogger("desktop-deep-link"); @@ -22,6 +23,7 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const preReadyOpenUrls = yield* DesktopPreReadyPlatform.DesktopPreReadyOpenUrls; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -45,12 +47,12 @@ export const make = Effect.gen(function* () { return DesktopDeepLinkRouter.of({ configure: Effect.gen(function* () { openFirstThreadLink(process.argv); + preReadyOpenUrls.setHandler((url) => { + openFirstThreadLink([url]); + }); yield* electronApp.on("second-instance", (_event, commandLine: string[]) => { openFirstThreadLink(commandLine); }); - yield* electronApp.on("open-url", (_event, url: string) => { - openFirstThreadLink([url]); - }); }).pipe(Effect.withSpan("desktop.deepLink.configure")), }); }); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..c4cb3a1cfa97 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -64,6 +64,31 @@ describe("DesktopPreReadyPlatform", () => { assert.isNull(value); }); + it("buffers a macOS URL opened before the desktop runtime is configured", () => { + let openUrlListener: ((event: unknown, url: string) => void) | undefined; + const openedUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({ + platform: "darwin", + electronApp: { + on: (eventName, listener) => { + assert.equal(eventName, "open-url"); + openUrlListener = listener; + }, + }, + }); + + if (!openUrlListener) { + throw new Error("open-url listener was not registered"); + } + openUrlListener({}, "t3code://app/#/environment-123/thread-456"); + + const handledUrls: string[] = []; + openedUrls.setHandler((url) => { + handledUrls.push(url); + }); + + assert.deepEqual(handledUrls, ["t3code://app/#/environment-123/thread-456"]); + }); + it("returns null for missing Electron command-line switches", () => { const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( { @@ -91,7 +116,7 @@ describe("DesktopPreReadyPlatform", () => { events.push("pre-ready"); }); - const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + const preReadyLayer = DesktopPreReadyPlatform.layer({ setHandler: () => {} }).pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0bb..d4d79d110447 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -17,6 +17,42 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } +export interface EarlyOpenUrlElectronApp { + readonly on: (eventName: "open-url", listener: (event: unknown, url: string) => void) => void; +} + +export interface EarlyOpenUrlBuffer { + readonly setHandler: (handler: (url: string) => void) => void; +} + +export function makeEarlyOpenUrlBuffer(input: { + readonly platform: NodeJS.Platform; + readonly electronApp: EarlyOpenUrlElectronApp; +}): EarlyOpenUrlBuffer { + if (input.platform !== "darwin") { + return { setHandler: () => {} }; + } + + const pendingUrls: string[] = []; + let handler: ((url: string) => void) | undefined; + input.electronApp.on("open-url", (_event, url) => { + if (handler) { + handler(url); + return; + } + pendingUrls.push(url); + }); + + return { + setHandler: (nextHandler) => { + handler = nextHandler; + for (const url of pendingUrls.splice(0)) { + handler(url); + } + }, + }; +} + export function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, @@ -46,6 +82,11 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} +export class DesktopPreReadyOpenUrls extends Context.Service< + DesktopPreReadyOpenUrls, + EarlyOpenUrlBuffer +>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyOpenUrls") {} + export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { @@ -68,7 +109,9 @@ export const make = Effect.gen(function* () { // Keep Electron's strict pre-ready setup isolated so later runtime layers cannot // observe app readiness before scheme privileges and command-line switches exist. -export const layer = Layer.mergeAll( - ElectronProtocol.layerSchemePrivileges, - Layer.effect(DesktopPreReadyElectronOptions, make), -); +export const layer = (openUrls: EarlyOpenUrlBuffer) => + Layer.mergeAll( + ElectronProtocol.layerSchemePrivileges, + Layer.effect(DesktopPreReadyElectronOptions, make), + Layer.succeed(DesktopPreReadyOpenUrls, openUrls), + ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 65a8dd2a8509..24e7f22d091d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -64,6 +64,11 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({ + platform: process.platform, + electronApp: Electron.app, +}); + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -213,7 +218,7 @@ const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), ), - Layer.provideMerge(DesktopPreReadyPlatform.layer), + Layer.provideMerge(DesktopPreReadyPlatform.layer(earlyOpenUrls)), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); From 0a6ae84ed35569327a0c7708958e1fb03a38c762 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 18 Aug 2026 10:49:11 +0100 Subject: [PATCH 4/5] fix(desktop): sequence early link setup --- .../src/app/DesktopPreReadyPlatform.test.ts | 45 ++++++++++++------- .../src/app/DesktopPreReadyPlatform.ts | 21 ++++++--- apps/desktop/src/main.ts | 23 ++++++---- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index c4cb3a1cfa97..1f11422b754a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -5,17 +5,18 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; -const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( - () => ({ +const { appendSwitchMock, appOnMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = + vi.hoisted(() => ({ appendSwitchMock: vi.fn(), + appOnMock: vi.fn(), getSwitchValueMock: vi.fn(), hasSwitchMock: vi.fn(), registerSchemesMock: vi.fn(), - }), -); + })); vi.mock("electron", () => ({ app: { + on: appOnMock, commandLine: { appendSwitch: appendSwitchMock, getSwitchValue: getSwitchValueMock, @@ -32,6 +33,7 @@ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { beforeEach(() => { appendSwitchMock.mockReset(); + appOnMock.mockReset(); getSwitchValueMock.mockReset(); hasSwitchMock.mockReset(); registerSchemesMock.mockReset(); @@ -115,8 +117,13 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockImplementation(() => { events.push("pre-ready"); }); + appOnMock.mockImplementation((eventName, listener) => { + assert.equal(eventName, "open-url"); + events.push("open-url"); + assert.equal(typeof listener, "function"); + }); - const preReadyLayer = DesktopPreReadyPlatform.layer({ setHandler: () => {} }).pipe( + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), ); @@ -130,24 +137,32 @@ describe("DesktopPreReadyPlatform", () => { ), ); - const runtimeLayer = clerkShapedLayer.pipe( - Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), - Layer.provideMerge(preReadyLayer), + const runtimeLayer = preReadyLayer.pipe( + Layer.flatMap((preReadyContext) => + clerkShapedLayer.pipe( + Layer.flatMap((clerkContext) => + Layer.mergeAll( + Layer.succeedContext(preReadyContext), + Layer.succeedContext(clerkContext), + ), + ), + ), + ), ); const result = yield* Effect.all({ clerk: ClerkShaped, preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, + openUrls: DesktopPreReadyPlatform.DesktopPreReadyOpenUrls, }).pipe(Effect.provide(runtimeLayer)); - assert.deepEqual(result, { - clerk: { ready: true }, - preReady: { - linux: null, - linuxPasswordStoreCommandLine: null, - }, + assert.deepEqual(result.clerk, { ready: true }); + assert.deepEqual(result.preReady, { + linux: null, + linuxPasswordStoreCommandLine: null, }); - assert.deepEqual(events, ["pre-ready", "clerk"]); + assert.isFunction(result.openUrls.setHandler); + assert.isTrue(events.indexOf("open-url") < events.indexOf("clerk")); assert.equal(registerSchemesMock.mock.calls.length, 1); assert.equal(appendSwitchMock.mock.calls.length, 0); }), diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index d4d79d110447..7b71677d5a3f 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -87,6 +87,16 @@ export class DesktopPreReadyOpenUrls extends Context.Service< EarlyOpenUrlBuffer >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyOpenUrls") {} +const makeOpenUrls = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.sync(() => + makeEarlyOpenUrlBuffer({ + platform, + electronApp: Electron.app, + }), + ); +}); + export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { @@ -109,9 +119,8 @@ export const make = Effect.gen(function* () { // Keep Electron's strict pre-ready setup isolated so later runtime layers cannot // observe app readiness before scheme privileges and command-line switches exist. -export const layer = (openUrls: EarlyOpenUrlBuffer) => - Layer.mergeAll( - ElectronProtocol.layerSchemePrivileges, - Layer.effect(DesktopPreReadyElectronOptions, make), - Layer.succeed(DesktopPreReadyOpenUrls, openUrls), - ); +export const layer = Layer.mergeAll( + ElectronProtocol.layerSchemePrivileges, + Layer.effect(DesktopPreReadyElectronOptions, make), + Layer.effect(DesktopPreReadyOpenUrls, makeOpenUrls), +); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 24e7f22d091d..c8bc6b6e94ee 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -64,11 +64,6 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; -const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({ - platform: process.platform, - electronApp: Electron.app, -}); - const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -214,11 +209,21 @@ const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( // Acquire strict pre-ready setup before Clerk, whose userData resolution can // yield and let Electron emit ready. -const desktopRuntimeLayer = desktopClerkLayer.pipe( - Layer.flatMap((clerkContext) => - desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), +const desktopRuntimeLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.flatMap((preReadyContext) => + desktopClerkLayer.pipe( + Layer.flatMap((clerkContext) => + desktopApplicationRuntimeLayer.pipe( + Layer.provideMerge( + Layer.mergeAll( + Layer.succeedContext(preReadyContext), + Layer.succeedContext(clerkContext), + ), + ), + ), + ), + ), ), - Layer.provideMerge(DesktopPreReadyPlatform.layer(earlyOpenUrls)), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); From 45c4b25abccfce974c162b2d2073f067929c7d70 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 18 Aug 2026 10:53:33 +0100 Subject: [PATCH 5/5] fix(desktop): preserve deep links during recovery --- apps/desktop/src/window/DesktopWindow.test.ts | 41 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 12 ++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 636fee83093d..835b346f1caf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -504,6 +504,47 @@ describe("DesktopWindow", () => { }), ); + it.effect("retries the external thread URL after a development load failure", () => + 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, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* desktopWindow.openThread({ + environmentId: "environment-123", + threadId: "thread-456", + }); + const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); + if (!didFailLoad) { + return yield* Effect.die("renderer load listener was not registered"); + } + + didFailLoad( + {}, + -9, + "ERR_UNEXPECTED", + "t3code-dev://app/#/environment-123/thread-456", + true, + ); + yield* TestClock.adjust(100); + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/"], + ["t3code-dev://app/#/environment-123/thread-456"], + ["t3code-dev://app/#/environment-123/thread-456"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("opens the normal app URL after a deep-link window is closed", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index a01bc3d331b9..85cc565ed699 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -278,6 +278,7 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + const applicationUrls = new WeakMap(); // A cold-start deep link is consumed by the window that loads it. A newer // link arriving during creation remains pending and navigates that window // once it is registered as main. @@ -363,6 +364,8 @@ export const make = Effect.gen(function* () { }); applicationUrl = pendingApplicationUrl ?? applicationUrl; pendingApplicationUrl = null; + applicationUrls.set(window, applicationUrl); + const getApplicationUrl = () => applicationUrls.get(window) ?? applicationUrl; if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -526,7 +529,7 @@ export const make = Effect.gen(function* () { window.webContents.on("will-navigate", (event, url) => { if ( isSameOriginRendererNavigation({ - applicationUrl, + applicationUrl: getApplicationUrl(), navigationUrl: url, }) ) { @@ -587,7 +590,7 @@ export const make = Effect.gen(function* () { if (window.isDestroyed()) { return; } - void window.loadURL(applicationUrl).catch(() => undefined); + void window.loadURL(getApplicationUrl()).catch(() => undefined); }; const scheduleDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { @@ -619,7 +622,7 @@ export const make = Effect.gen(function* () { if ( environment.isDevelopment && !isSameOriginRendererNavigation({ - applicationUrl, + applicationUrl: getApplicationUrl(), navigationUrl: window.webContents.getURL(), }) ) { @@ -638,7 +641,7 @@ export const make = Effect.gen(function* () { const retryInMs = environment.isDevelopment && isRetryableDevelopmentRendererLoadFailure({ - applicationUrl, + applicationUrl: getApplicationUrl(), errorCode, isMainFrame, validatedUrl: validatedURL, @@ -769,6 +772,7 @@ export const make = Effect.gen(function* () { if (window === null || pendingApplicationUrl !== applicationUrl) return; pendingApplicationUrl = null; + applicationUrls.set(window, applicationUrl); void window.loadURL(applicationUrl).catch(() => undefined); yield* electronWindow.reveal(window); }),