From 788c88bfacce4286b8699930de2e107d631e7756 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:59:44 +0700 Subject: [PATCH 1/6] fix(desktop): forward second-instance deep links to the running app Fixes #5978. Second-instance argv and macOS open-url now extract t3code:// URLs and dispatch them instead of only focusing the window. --- apps/desktop/src/app/DesktopClerk.test.ts | 140 +++++++++++++++++- apps/desktop/src/app/DesktopClerk.ts | 43 +++++- .../src/app/desktopProtocolUrl.test.ts | 54 +++++++ apps/desktop/src/app/desktopProtocolUrl.ts | 16 ++ 4 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/desktopProtocolUrl.test.ts create mode 100644 apps/desktop/src/app/desktopProtocolUrl.ts diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..bcbf6811f8d0 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -24,15 +24,21 @@ vi.mock("@clerk/electron/storage", () => ({ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { +const makeDesktopClerkLayer = ( + isDevelopment = true, + events: string[] = [], + platform: NodeJS.Platform = "linux", +) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + platform, appDataDirectory: "/tmp/app-data", userDataDirName: isDevelopment ? "t3code-dev" : "t3code", legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", @@ -181,6 +187,138 @@ describe("DesktopClerk", () => { ); }); + it.effect("loads a second-instance protocol URL on the existing window", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const loadURL = vi.fn(() => Promise.resolve()); + const mainWindow = { loadURL }; + const revealed: unknown[] = []; + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), + reveal: (window: unknown) => + Effect.sync(() => { + revealed.push(window); + }), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + const url = "t3code-dev://app/CLERK-ROUTER/VIRTUAL/sign-in?__clerk_status=complete"; + listeners.get("second-instance")?.({}, ["electron", "--hidden", url], process.cwd()); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.deepEqual(revealed, [mainWindow]); + assert.deepEqual(loadURL.mock.calls, [[url]]); + }), + ); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + + it.effect("reveals the window when second-instance argv has no protocol URL", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const loadURL = vi.fn(() => Promise.resolve()); + const mainWindow = { loadURL }; + const revealed: unknown[] = []; + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), + reveal: (window: unknown) => + Effect.sync(() => { + revealed.push(window); + }), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + listeners.get("second-instance")?.({}, ["electron", "--hidden"], process.cwd()); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.deepEqual(revealed, [mainWindow]); + }), + ); + assert.deepEqual(loadURL.mock.calls, []); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + + it.effect("loads macOS open-url deep links on the existing window", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const loadURL = vi.fn(() => Promise.resolve()); + const mainWindow = { loadURL }; + const revealed: unknown[] = []; + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), + reveal: (window: unknown) => + Effect.sync(() => { + revealed.push(window); + }), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + assert.deepEqual([...listeners.keys()], ["second-instance", "open-url"]); + + const url = "t3code-dev://app/sso-callback"; + const preventDefault = vi.fn(); + listeners.get("open-url")?.({ preventDefault }, url); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(preventDefault.mock.calls.length, 1); + assert.deepEqual(revealed, [mainWindow]); + assert.deepEqual(loadURL.mock.calls, [[url]]); + }), + ); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer(true, [], "darwin")), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + it.effect("quits and interrupts startup in a secondary instance", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..0150cd63c651 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -13,6 +13,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { extractDesktopProtocolUrl, isDesktopProtocolUrl } from "./desktopProtocolUrl.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -53,6 +54,8 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} +const isStringArg = (value: unknown): value is string => typeof value === "string"; + export function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { @@ -135,16 +138,42 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const revealAndDispatch = Effect.fn("desktop.clerk.revealAndDispatchProtocolUrl")(function* ( + url: string | null, + ) { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(mainWindow)) { + return; + } + yield* electronWindow.reveal(mainWindow.value); + if (url === null) { + return; + } + // Same path as first-launch renderer loads: the custom protocol serves + // t3code:// (and t3code-dev://) on the existing window. + yield* Effect.sync(() => { + void Promise.resolve(mainWindow.value.loadURL(url)).catch(() => undefined); + }); + }); + + yield* electronApp.on("second-instance", (_event, argv) => { void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), + revealAndDispatch( + extractDesktopProtocolUrl(Array.isArray(argv) ? argv.filter(isStringArg) : [], scheme), + ), ); }); + + if (environment.platform === "darwin") { + yield* electronApp.on("open-url", (event, url) => { + if (typeof url !== "string" || !isDesktopProtocolUrl(url, scheme)) { + return; + } + (event as { preventDefault?: () => void } | undefined)?.preventDefault?.(); + void runPromise(revealAndDispatch(url)); + }); + } }).pipe(Effect.withSpan("desktop.clerk.configure")), }); }); diff --git a/apps/desktop/src/app/desktopProtocolUrl.test.ts b/apps/desktop/src/app/desktopProtocolUrl.test.ts new file mode 100644 index 000000000000..09611aff4221 --- /dev/null +++ b/apps/desktop/src/app/desktopProtocolUrl.test.ts @@ -0,0 +1,54 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { extractDesktopProtocolUrl } from "./desktopProtocolUrl.ts"; + +describe("extractDesktopProtocolUrl", () => { + it.each([ + { + name: "returns a t3code:// URL", + argv: ["/opt/t3code-bin/t3code", "t3code://app/sso-callback"], + scheme: "t3code", + expected: "t3code://app/sso-callback", + }, + { + name: "returns a t3code-dev:// URL", + argv: ["electron", "t3code-dev://app/CLERK-ROUTER/VIRTUAL/sign-in"], + scheme: "t3code-dev", + expected: "t3code-dev://app/CLERK-ROUTER/VIRTUAL/sign-in", + }, + { + name: "returns null when no protocol URL is present", + argv: ["/opt/t3code-bin/t3code", "--hidden"], + scheme: "t3code", + expected: null, + }, + { + name: "ignores flags, empty strings, and file paths", + argv: [ + "", + "--inspect", + "-foo", + "--t3code://app/from-flag", + "/Users/alice/Projects/t3code", + "C:\\Program Files\\T3 Code\\T3 Code.exe", + "t3code://app/from-url", + ], + scheme: "t3code", + expected: "t3code://app/from-url", + }, + { + name: "prefers the last matching URL", + argv: ["t3code://app/first", "--verbose", "t3code://app/second"], + scheme: "t3code", + expected: "t3code://app/second", + }, + { + name: "does not match a different desktop scheme", + argv: ["t3code-dev://app/", "t3code://app/prod"], + scheme: "t3code-dev", + expected: "t3code-dev://app/", + }, + ])("$name", ({ argv, scheme, expected }) => { + assert.equal(extractDesktopProtocolUrl(argv, scheme), expected); + }); +}); diff --git a/apps/desktop/src/app/desktopProtocolUrl.ts b/apps/desktop/src/app/desktopProtocolUrl.ts new file mode 100644 index 000000000000..52ff828689f8 --- /dev/null +++ b/apps/desktop/src/app/desktopProtocolUrl.ts @@ -0,0 +1,16 @@ +// Used by second-instance argv and macOS open-url to find the custom-scheme +// URL the already-running desktop window should load. +export function isDesktopProtocolUrl(value: string, scheme: string): boolean { + return value.startsWith(`${scheme}://`); +} + +export function extractDesktopProtocolUrl(argv: readonly string[], scheme: string): string | null { + let found: string | null = null; + for (const arg of argv) { + if (arg.length === 0 || arg.startsWith("-") || !isDesktopProtocolUrl(arg, scheme)) { + continue; + } + found = arg; + } + return found; +} From 4c8f9f93faa715e249f767d68fc9f6f6d33c0671 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:36:25 +0700 Subject: [PATCH 2/6] fix(desktop): queue deep links until the main window exists Cold-launch open-url and WSL splash both left protocol URLs undelivered: the handler returned after preventDefault, or loadURL hit the splash. Queue the latest URL and apply it on the registered main window after createMain. --- apps/desktop/src/app/DesktopClerk.test.ts | 129 ++++++++++++++++++ apps/desktop/src/app/DesktopClerk.ts | 50 +++++-- .../src/app/desktopProtocolUrl.test.ts | 31 ++++- apps/desktop/src/app/desktopProtocolUrl.ts | 32 +++++ apps/desktop/src/window/DesktopWindow.ts | 4 + 5 files changed, 232 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index bcbf6811f8d0..f3f2bd1d7f29 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -27,8 +27,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { + applyPendingDesktopProtocolUrl, + takePendingDesktopProtocolUrl, +} from "./desktopProtocolUrl.ts"; const makeDesktopClerkLayer = ( isDevelopment = true, @@ -63,10 +68,15 @@ const makeDesktopClerkLayer = ( ); }; +const unusedDesktopWindow = { + createMainIfBackendReady: Effect.void, +} as unknown as DesktopWindow.DesktopWindow["Service"]; + describe("DesktopClerk", () => { beforeEach(() => { createClerkBridgeMock.mockReset(); storageMock.mockReset(); + takePendingDesktopProtocolUrl(); }); it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { @@ -184,6 +194,7 @@ describe("DesktopClerk", () => { Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), ); }); @@ -202,6 +213,7 @@ describe("DesktopClerk", () => { }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { + main: Effect.succeed(Option.some(mainWindow)), currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), reveal: (window: unknown) => Effect.sync(() => { @@ -227,6 +239,7 @@ describe("DesktopClerk", () => { Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), ); }); @@ -245,6 +258,7 @@ describe("DesktopClerk", () => { }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { + main: Effect.succeed(Option.some(mainWindow)), currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), reveal: (window: unknown) => Effect.sync(() => { @@ -269,6 +283,7 @@ describe("DesktopClerk", () => { Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), ); }); @@ -287,6 +302,7 @@ describe("DesktopClerk", () => { }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { + main: Effect.succeed(Option.some(mainWindow)), currentMainOrFirst: Effect.succeed(Option.some(mainWindow)), reveal: (window: unknown) => Effect.sync(() => { @@ -316,6 +332,119 @@ describe("DesktopClerk", () => { Effect.provide(makeDesktopClerkLayer(true, [], "darwin")), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), + ); + }); + + it.effect("queues macOS open-url when no window exists and later dispatches", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const loadURL = vi.fn(() => Promise.resolve()); + const mainWindow = { loadURL }; + const createMainAttempts: string[] = []; + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + reveal: () => Effect.die("unexpected reveal before main exists"), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + const desktopWindow = { + createMainIfBackendReady: Effect.sync(() => { + createMainAttempts.push("createMainIfBackendReady"); + }), + } as unknown as DesktopWindow.DesktopWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + const url = "t3code-dev://app/sso-callback"; + const preventDefault = vi.fn(); + listeners.get("open-url")?.({ preventDefault }, url); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(preventDefault.mock.calls.length, 1); + assert.deepEqual(createMainAttempts, ["createMainIfBackendReady"]); + assert.deepEqual(loadURL.mock.calls, []); + }), + ); + + // Same seam DesktopWindow.createMain uses after setMain. + assert.equal(applyPendingDesktopProtocolUrl(mainWindow), true); + assert.deepEqual(loadURL.mock.calls, [[url]]); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer(true, [], "darwin")), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, desktopWindow), + ); + }); + + it.effect("does not load a protocol URL on the WSL connecting splash", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const splashLoadURL = vi.fn(() => Promise.resolve()); + const mainLoadURL = vi.fn(() => Promise.resolve()); + const splashWindow = { loadURL: splashLoadURL }; + const mainWindow = { loadURL: mainLoadURL }; + const revealed: unknown[] = []; + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const createMainAttempts: string[] = []; + const electronWindow = { + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.some(splashWindow)), + reveal: (window: unknown) => + Effect.sync(() => { + revealed.push(window); + }), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + const desktopWindow = { + createMainIfBackendReady: Effect.sync(() => { + createMainAttempts.push("createMainIfBackendReady"); + }), + } as unknown as DesktopWindow.DesktopWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + const url = "t3code-dev://app/sso-callback"; + listeners.get("second-instance")?.({}, ["electron", url], process.cwd()); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.deepEqual(createMainAttempts, ["createMainIfBackendReady"]); + assert.deepEqual(splashLoadURL.mock.calls, []); + }), + ); + assert.deepEqual(revealed, []); + assert.deepEqual(mainLoadURL.mock.calls, []); + + assert.equal(applyPendingDesktopProtocolUrl(mainWindow), true); + assert.deepEqual(splashLoadURL.mock.calls, []); + assert.deepEqual(mainLoadURL.mock.calls, [[url]]); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, desktopWindow), ); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0150cd63c651..9bf63227dd00 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -11,9 +11,15 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import { extractDesktopProtocolUrl, isDesktopProtocolUrl } from "./desktopProtocolUrl.ts"; +import { + extractDesktopProtocolUrl, + isDesktopProtocolUrl, + loadDesktopProtocolUrl, + queuePendingDesktopProtocolUrl, +} from "./desktopProtocolUrl.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -49,7 +55,10 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + | ElectronApp.ElectronApp + | ElectronWindow.ElectronWindow + | DesktopWindow.DesktopWindow + | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -125,8 +134,6 @@ export const make = Effect.gen(function* () { configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); // The SDK bridge holds Electron's single-instance lock (acquired at // bridge creation) so OAuth deep-link callbacks on Windows/Linux are @@ -138,23 +145,40 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const context = yield* Effect.context< + ElectronWindow.ElectronWindow | DesktopWindow.DesktopWindow + >(); + const runPromise = Effect.runPromiseWith(context); + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); const revealAndDispatch = Effect.fn("desktop.clerk.revealAndDispatchProtocolUrl")(function* ( url: string | null, ) { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isNone(mainWindow)) { + // Registered main only. currentMainOrFirst can be the WSL splash. + const mainWindow = yield* electronWindow.main; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + if (url !== null) { + yield* Effect.sync(() => { + loadDesktopProtocolUrl(mainWindow.value, url); + }); + } return; } - yield* electronWindow.reveal(mainWindow.value); - if (url === null) { + + if (url !== null) { + queuePendingDesktopProtocolUrl(url); + // Opens the real main when the backend is already ready (macOS with + // no windows). Otherwise createMain applies the queued URL later. + yield* desktopWindow.createMainIfBackendReady.pipe(Effect.ignore); return; } - // Same path as first-launch renderer loads: the custom protocol serves - // t3code:// (and t3code-dev://) on the existing window. - yield* Effect.sync(() => { - void Promise.resolve(mainWindow.value.loadURL(url)).catch(() => undefined); - }); + + const fallbackWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(fallbackWindow)) { + yield* electronWindow.reveal(fallbackWindow.value); + } }); yield* electronApp.on("second-instance", (_event, argv) => { diff --git a/apps/desktop/src/app/desktopProtocolUrl.test.ts b/apps/desktop/src/app/desktopProtocolUrl.test.ts index 09611aff4221..eb8e3a764d49 100644 --- a/apps/desktop/src/app/desktopProtocolUrl.test.ts +++ b/apps/desktop/src/app/desktopProtocolUrl.test.ts @@ -1,6 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; +import { beforeEach } from "vite-plus/test"; -import { extractDesktopProtocolUrl } from "./desktopProtocolUrl.ts"; +import { + applyPendingDesktopProtocolUrl, + extractDesktopProtocolUrl, + queuePendingDesktopProtocolUrl, + takePendingDesktopProtocolUrl, +} from "./desktopProtocolUrl.ts"; describe("extractDesktopProtocolUrl", () => { it.each([ @@ -52,3 +58,26 @@ describe("extractDesktopProtocolUrl", () => { assert.equal(extractDesktopProtocolUrl(argv, scheme), expected); }); }); + +describe("pending desktop protocol URL", () => { + beforeEach(() => { + takePendingDesktopProtocolUrl(); + }); + + it("keeps the latest queued URL until it is applied", () => { + const loaded: string[] = []; + const loadURL = (url: string) => { + loaded.push(url); + }; + + assert.equal(takePendingDesktopProtocolUrl(), null); + assert.equal(applyPendingDesktopProtocolUrl({ loadURL }), false); + + queuePendingDesktopProtocolUrl("t3code://app/first"); + queuePendingDesktopProtocolUrl("t3code://app/second"); + assert.equal(applyPendingDesktopProtocolUrl({ loadURL }), true); + assert.deepEqual(loaded, ["t3code://app/second"]); + assert.equal(takePendingDesktopProtocolUrl(), null); + assert.equal(applyPendingDesktopProtocolUrl({ loadURL }), false); + }); +}); diff --git a/apps/desktop/src/app/desktopProtocolUrl.ts b/apps/desktop/src/app/desktopProtocolUrl.ts index 52ff828689f8..696703b19300 100644 --- a/apps/desktop/src/app/desktopProtocolUrl.ts +++ b/apps/desktop/src/app/desktopProtocolUrl.ts @@ -14,3 +14,35 @@ export function extractDesktopProtocolUrl(argv: readonly string[], scheme: strin } return found; } + +export function loadDesktopProtocolUrl( + window: { readonly loadURL: (url: string) => unknown }, + url: string, +): void { + void Promise.resolve(window.loadURL(url)).catch(() => undefined); +} + +// Latest protocol URL received before a real main window exists (cold launch +// or WSL connecting splash). DesktopWindow.createMain applies it after setMain. +let pendingDesktopProtocolUrl: string | null = null; + +export function queuePendingDesktopProtocolUrl(url: string): void { + pendingDesktopProtocolUrl = url; +} + +export function takePendingDesktopProtocolUrl(): string | null { + const url = pendingDesktopProtocolUrl; + pendingDesktopProtocolUrl = null; + return url; +} + +export function applyPendingDesktopProtocolUrl(window: { + readonly loadURL: (url: string) => unknown; +}): boolean { + const url = takePendingDesktopProtocolUrl(); + if (url === null) { + return false; + } + loadDesktopProtocolUrl(window, url); + return true; +} diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 9018b9b92c2a..573a01632a86 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,6 +13,7 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import { applyPendingDesktopProtocolUrl } from "../app/desktopProtocolUrl.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -752,6 +753,9 @@ export const make = Effect.gen(function* () { const window = yield* createWindow(); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); + yield* Effect.sync(() => { + applyPendingDesktopProtocolUrl(window); + }); return window; }).pipe(Effect.withSpan("desktop.window.createMain")); From 2beb86cb5e2d49a2603b4e69a74995c10a8da370 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:43:13 +0700 Subject: [PATCH 3/6] fix(desktop): serialize deep-link main window creation Concurrent open-url and second-instance events now share one create gate so they cannot each open an untracked main window. --- apps/desktop/src/app/DesktopClerk.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9bf63227dd00..a0397d1b5433 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; @@ -150,6 +151,7 @@ export const make = Effect.gen(function* () { ElectronWindow.ElectronWindow | DesktopWindow.DesktopWindow >(); const runPromise = Effect.runPromiseWith(context); + const createMainGate = yield* Semaphore.make(1); const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); const revealAndDispatch = Effect.fn("desktop.clerk.revealAndDispatchProtocolUrl")(function* ( @@ -169,9 +171,23 @@ export const make = Effect.gen(function* () { if (url !== null) { queuePendingDesktopProtocolUrl(url); - // Opens the real main when the backend is already ready (macOS with - // no windows). Otherwise createMain applies the queued URL later. - yield* desktopWindow.createMainIfBackendReady.pipe(Effect.ignore); + // Serialize so concurrent open-url / second-instance events cannot + // each pass the empty-main check and create two untracked windows. + yield* createMainGate.withPermits(1)( + Effect.gen(function* () { + const existing = yield* electronWindow.main; + if (Option.isSome(existing)) { + yield* electronWindow.reveal(existing.value); + yield* Effect.sync(() => { + loadDesktopProtocolUrl(existing.value, url); + }); + return; + } + // Opens the real main when the backend is already ready (macOS + // with no windows). Otherwise createMain applies the queued URL. + yield* desktopWindow.createMainIfBackendReady.pipe(Effect.ignore); + }), + ); return; } From 5526327fce7153e09ad8b5b5ce0a0117cd45dee3 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:59:05 +0700 Subject: [PATCH 4/6] fix(desktop): keep queued deep links ahead of home retries Second-instance and open-url callbacks could lose an SSO URL: createMain loaded home first, development retries reloaded home, and a gate waiter could apply an older fiber-local URL after a newer one was already queued. --- apps/desktop/src/app/DesktopClerk.test.ts | 59 +++++++++++++++++++ apps/desktop/src/app/DesktopClerk.ts | 10 +++- .../src/app/desktopProtocolUrl.test.ts | 13 ++++ apps/desktop/src/app/desktopProtocolUrl.ts | 19 ++++++ apps/desktop/src/window/DesktopWindow.test.ts | 31 ++++++++++ apps/desktop/src/window/DesktopWindow.ts | 18 +++++- 6 files changed, 145 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index f3f2bd1d7f29..92c5335a9fd7 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,6 +22,7 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -389,6 +390,64 @@ describe("DesktopClerk", () => { ); }); + it.effect("does not apply a stale deep link after a newer one is queued", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const loadURL = vi.fn(() => Promise.resolve()); + const mainWindow = { loadURL }; + let currentMain = Option.none(); + const electronApp = { + quit: Effect.void, + on: (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + main: Effect.sync(() => currentMain), + currentMainOrFirst: Effect.sync(() => currentMain), + reveal: () => Effect.void, + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + return Effect.scoped( + Effect.gen(function* () { + const enteredCreate = yield* Deferred.make(); + const releaseCreate = yield* Deferred.make(); + const desktopWindow = { + createMainIfBackendReady: Effect.gen(function* () { + yield* Deferred.succeed(enteredCreate, undefined); + yield* Deferred.await(releaseCreate); + applyPendingDesktopProtocolUrl(mainWindow); + currentMain = Option.some(mainWindow); + }), + } as unknown as DesktopWindow.DesktopWindow["Service"]; + + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure.pipe( + Effect.provideService(DesktopWindow.DesktopWindow, desktopWindow), + ); + + const older = "t3code-dev://app/sso-callback?state=old"; + const newer = "t3code-dev://app/sso-callback?state=new"; + listeners.get("open-url")?.({ preventDefault: vi.fn() }, older); + yield* Deferred.await(enteredCreate); + listeners.get("open-url")?.({ preventDefault: vi.fn() }, newer); + yield* Deferred.succeed(releaseCreate, undefined); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.deepEqual(loadURL.mock.calls, [[newer]]); + }), + ); + assert.equal(takePendingDesktopProtocolUrl(), null); + }), + ).pipe( + Effect.provide(makeDesktopClerkLayer(true, [], "darwin")), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + it.effect("does not load a protocol URL on the WSL connecting splash", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index a0397d1b5433..050bd0054d68 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -16,9 +16,9 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { + applyPendingDesktopProtocolUrl, extractDesktopProtocolUrl, isDesktopProtocolUrl, - loadDesktopProtocolUrl, queuePendingDesktopProtocolUrl, } from "./desktopProtocolUrl.ts"; @@ -163,7 +163,11 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(mainWindow.value); if (url !== null) { yield* Effect.sync(() => { - loadDesktopProtocolUrl(mainWindow.value, url); + // Queue then apply so a later waiter cannot load a stale + // fiber-local URL over a newer deep link, and createMain cannot + // replay the older pending slot. + queuePendingDesktopProtocolUrl(url); + applyPendingDesktopProtocolUrl(mainWindow.value); }); } return; @@ -179,7 +183,7 @@ export const make = Effect.gen(function* () { if (Option.isSome(existing)) { yield* electronWindow.reveal(existing.value); yield* Effect.sync(() => { - loadDesktopProtocolUrl(existing.value, url); + applyPendingDesktopProtocolUrl(existing.value); }); return; } diff --git a/apps/desktop/src/app/desktopProtocolUrl.test.ts b/apps/desktop/src/app/desktopProtocolUrl.test.ts index eb8e3a764d49..f8c72793435d 100644 --- a/apps/desktop/src/app/desktopProtocolUrl.test.ts +++ b/apps/desktop/src/app/desktopProtocolUrl.test.ts @@ -4,7 +4,9 @@ import { beforeEach } from "vite-plus/test"; import { applyPendingDesktopProtocolUrl, extractDesktopProtocolUrl, + loadDesktopProtocolUrl, queuePendingDesktopProtocolUrl, + registerDesktopProtocolWindowLoader, takePendingDesktopProtocolUrl, } from "./desktopProtocolUrl.ts"; @@ -80,4 +82,15 @@ describe("pending desktop protocol URL", () => { assert.equal(takePendingDesktopProtocolUrl(), null); assert.equal(applyPendingDesktopProtocolUrl({ loadURL }), false); }); + + it("loads through a registered window loader so retries can track the URL", () => { + const loaded: string[] = []; + const window = { loadURL: (url: string) => loaded.push(`direct:${url}`) }; + registerDesktopProtocolWindowLoader(window, (url) => { + loaded.push(`loader:${url}`); + }); + + loadDesktopProtocolUrl(window, "t3code://app/sso-callback"); + assert.deepEqual(loaded, ["loader:t3code://app/sso-callback"]); + }); }); diff --git a/apps/desktop/src/app/desktopProtocolUrl.ts b/apps/desktop/src/app/desktopProtocolUrl.ts index 696703b19300..db2c2c34ee00 100644 --- a/apps/desktop/src/app/desktopProtocolUrl.ts +++ b/apps/desktop/src/app/desktopProtocolUrl.ts @@ -15,10 +15,29 @@ export function extractDesktopProtocolUrl(argv: readonly string[], scheme: strin return found; } +type DesktopProtocolWindowLoader = (url: string) => void; + +// DesktopWindow registers the loader that also updates the window's intended +// URL, so development did-fail-load retries cannot replace a deep link with +// the default home URL. +const desktopProtocolWindowLoaders = new WeakMap(); + +export function registerDesktopProtocolWindowLoader( + window: object, + load: DesktopProtocolWindowLoader, +): void { + desktopProtocolWindowLoaders.set(window, load); +} + export function loadDesktopProtocolUrl( window: { readonly loadURL: (url: string) => unknown }, url: string, ): void { + const load = desktopProtocolWindowLoaders.get(window); + if (load !== undefined) { + load(url); + return; + } void Promise.resolve(window.loadURL(url)).catch(() => undefined); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 42ba818acf5f..72b1a907ac1f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -43,6 +43,7 @@ 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 { queuePendingDesktopProtocolUrl } from "../app/desktopProtocolUrl.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; @@ -1036,6 +1037,36 @@ describe("DesktopWindow", () => { }), ); + it.effect("retries a queued deep link instead of the home URL", () => + 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 deepLink = "t3code-dev://app/sso-callback"; + + yield* Effect.gen(function* () { + queuePendingDesktopProtocolUrl(deepLink); + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); + if (!didFailLoad) { + return yield* Effect.die("renderer load listeners were not registered"); + } + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [[deepLink]]); + didFailLoad({}, -9, "ERR_UNEXPECTED", deepLink, true); + yield* TestClock.adjust(100); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [[deepLink], [deepLink]]); + }).pipe(Effect.provide(layer)); + }), + ); + it("retries only transient failures for the development renderer", () => { assert.isTrue( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 573a01632a86..b27b3486fce1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,7 +13,11 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; -import { applyPendingDesktopProtocolUrl } from "../app/desktopProtocolUrl.ts"; +import { + applyPendingDesktopProtocolUrl, + registerDesktopProtocolWindowLoader, + takePendingDesktopProtocolUrl, +} from "../app/desktopProtocolUrl.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -612,12 +616,18 @@ export const make = Effect.gen(function* () { developmentLoadRetryFiber = undefined; runFork(Fiber.interrupt(retryFiber)); }; + let currentLoadUrl = applicationUrl; const loadApplication = () => { if (window.isDestroyed()) { return; } - void window.loadURL(applicationUrl).catch(() => undefined); + void window.loadURL(currentLoadUrl).catch(() => undefined); }; + registerDesktopProtocolWindowLoader(window, (url) => { + currentLoadUrl = url; + clearDevelopmentLoadRetry(); + loadApplication(); + }); const scheduleDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { return undefined; @@ -735,6 +745,10 @@ export const make = Effect.gen(function* () { void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); }); + const pendingUrl = takePendingDesktopProtocolUrl(); + if (pendingUrl !== null) { + currentLoadUrl = pendingUrl; + } loadApplication(); if (environment.isDevelopment) { window.webContents.openDevTools({ mode: "detach" }); From ea014d37d0f5448f8afea4ed1de690705e29e346 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 17:07:46 +0700 Subject: [PATCH 5/6] fix(desktop): recover to home after a deep link loads currentLoadUrl kept the SSO callback after did-finish-load, so renderer crash recovery replayed the one-shot protocol URL instead of the app home. --- apps/desktop/src/window/DesktopWindow.test.ts | 32 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 3 ++ 2 files changed, 35 insertions(+) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 72b1a907ac1f..8bdbaedf19c3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1067,6 +1067,38 @@ describe("DesktopWindow", () => { }), ); + it.effect("recovers to the home URL after a deep link finishes loading", () => + 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 deepLink = "t3code-dev://app/sso-callback"; + + yield* Effect.gen(function* () { + queuePendingDesktopProtocolUrl(deepLink); + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); + const renderProcessGone = fakeWindow.webContentsListeners.get("render-process-gone"); + if (!didFinishLoad || !renderProcessGone) { + return yield* Effect.die("renderer load listeners were not registered"); + } + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [[deepLink]]); + didFinishLoad(); + renderProcessGone({}, { reason: "crashed", exitCode: 1 }); + yield* TestClock.adjust(500); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [[deepLink], ["t3code-dev://app/"]]); + }).pipe(Effect.provide(layer)); + }), + ); + it("retries only transient failures for the development renderer", () => { assert.isTrue( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b27b3486fce1..8ea1e7f2fcf8 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -666,6 +666,9 @@ export const make = Effect.gen(function* () { } clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; + // Deep links (SSO callbacks) are one-shot. After they land, later + // recovery must reload the app home rather than replaying the callback. + currentLoadUrl = applicationUrl; window.setTitle(environment.displayName); }); window.webContents.on( From 7087b0d21129246b6679d8eff9737310b5a8c99e Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 17:56:45 +0700 Subject: [PATCH 6/6] fix(desktop): serialize main window create across ready and activate The Clerk create gate only serialized open-url / second-instance handlers. handleBackendReady and activate could still pass the splash-filtered empty check before setMain and open a second untracked main, dropping the queued OAuth deep link. Share one DesktopWindow permit and re-check before create. --- apps/desktop/src/window/DesktopWindow.test.ts | 68 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 54 +++++++++++---- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8bdbaedf19c3..9a141bc5e3f2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -299,7 +299,15 @@ function makeTestLayer(input: { // currentMainOrFirst mirrors the real fallback to the first live window (the // splash, before any main is registered). Reveal targets are recorded so tests // can assert what activation actually surfaced. -const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => +const makeSplashScenario = ( + createOutcomes: readonly (Electron.BrowserWindow | null)[], + options?: { + readonly holdMainCreate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + }, +) => Effect.gen(function* () { const createdWindows = yield* Ref.make([]); const createCalls = yield* Ref.make(0); @@ -350,6 +358,14 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); } yield* Ref.update(createdWindows, (windows) => [...windows, outcome]); + // Hold only the first real main create so a concurrent + // createMainIfBackendReady/activate can observe splash-filtered + // "no main" before setMain. Later creates must not wait — that + // is how an unserialized race opens a second window. + if (index === 1 && options?.holdMainCreate !== undefined) { + yield* Deferred.succeed(options.holdMainCreate.started, undefined); + yield* Deferred.await(options.holdMainCreate.release); + } return outcome; }), main: Ref.get(mainWindow), @@ -1164,6 +1180,56 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "does not create a second main when backend-ready races a deep-link create while the splash is showing", + () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const firstMain = makeFakeBrowserWindow(); + const secondMain = makeFakeBrowserWindow(); + const holdMainCreate = { + started: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const scenario = yield* makeSplashScenario( + [splash.window, firstMain.window, secondMain.window], + { holdMainCreate }, + ); + const deepLink = "t3code-dev://app/sso-callback"; + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.showConnectingSplash; + queuePendingDesktopProtocolUrl(deepLink); + + const ready = yield* desktopWindow + .handleBackendReady(new URL("http://127.0.0.1:3773")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(holdMainCreate.started); + + const extra = yield* desktopWindow.createMainIfBackendReady.pipe( + Effect.forkChild({ startImmediately: true }), + ); + const activated = yield* desktopWindow.activate.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(holdMainCreate.release, undefined); + + yield* Fiber.join(ready); + yield* Fiber.join(extra); + yield* Fiber.join(activated); + + assert.equal(yield* Ref.get(scenario.createCalls), 2); + const registeredMain = yield* Ref.get(scenario.mainWindow); + assert.isTrue(Option.isSome(registeredMain)); + assert.equal(Option.getOrThrow(registeredMain), firstMain.window); + assert.deepEqual(firstMain.loadURL.mock.calls, [[deepLink]]); + assert.deepEqual(secondMain.loadURL.mock.calls, []); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + it.effect( "retries opening the real main on activate when a failed post-readiness open left only the splash", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 8ea1e7f2fcf8..68bec25891c6 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"; @@ -285,6 +286,11 @@ 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); + // createMainIfBackendReady, ensureMain, and activate all check for an + // existing main then create. A WSL splash makes currentMainWindow None + // until setMain, so those callers must not race each other — or a deep + // link can load on a window that later loses the registered-main slot. + const createMainGate = 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()); @@ -766,7 +772,7 @@ export const make = Effect.gen(function* () { return window; }); - const createMain = Effect.gen(function* () { + const createMainUnlocked = Effect.gen(function* () { const window = yield* createWindow(); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); @@ -776,13 +782,19 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.createMain")); - const ensureMain = Effect.gen(function* () { - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) { - return existingWindow.value; - } - return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + const createMain = createMainGate.withPermits(1)(createMainUnlocked); + + const ensureMain = createMainGate + .withPermits(1)( + Effect.gen(function* () { + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + return existingWindow.value; + } + return yield* createMainUnlocked; + }), + ) + .pipe(Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -790,13 +802,25 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); - 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; - }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + const createMainIfBackendReady = createMainGate + .withPermits(1)( + Effect.gen(function* () { + const backendReady = yield* Ref.get(backendReadyRef); + if (!backendReady) return; + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + // A deep link may have been queued while another caller held the + // create gate. Apply it to the registered main instead of opening + // a second window that would overwrite setMain. + yield* Effect.sync(() => { + applyPendingDesktopProtocolUrl(existingWindow.value); + }); + return; + } + yield* createMainUnlocked; + }), + ) + .pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { // Only when nothing is shown yet: no real window, no existing splash.