diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..92c5335a9fd7 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,17 +22,29 @@ 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"; 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, 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)", @@ -57,10 +69,15 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { ); }; +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", () => { @@ -178,6 +195,315 @@ describe("DesktopClerk", () => { Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), + ); + }); + + 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 = { + main: Effect.succeed(Option.some(mainWindow)), + 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), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), + ); + }); + + 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 = { + main: Effect.succeed(Option.some(mainWindow)), + 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), + Effect.provideService(DesktopWindow.DesktopWindow, unusedDesktopWindow), + ); + }); + + 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 = { + main: Effect.succeed(Option.some(mainWindow)), + 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), + 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 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 }); + 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 9611dc083d2f..050bd0054d68 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -5,14 +5,22 @@ 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"; 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 { + applyPendingDesktopProtocolUrl, + extractDesktopProtocolUrl, + isDesktopProtocolUrl, + queuePendingDesktopProtocolUrl, +} from "./desktopProtocolUrl.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -48,11 +56,16 @@ 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") {} +const isStringArg = (value: unknown): value is string => typeof value === "string"; + export function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { @@ -122,8 +135,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 @@ -135,16 +146,78 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const context = yield* Effect.context< + 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* ( + url: string | null, + ) { + // 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(() => { + // 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; + } + + if (url !== null) { + queuePendingDesktopProtocolUrl(url); + // 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(() => { + applyPendingDesktopProtocolUrl(existing.value); + }); + 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; + } + + const fallbackWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(fallbackWindow)) { + yield* electronWindow.reveal(fallbackWindow.value); + } + }); + + 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..f8c72793435d --- /dev/null +++ b/apps/desktop/src/app/desktopProtocolUrl.test.ts @@ -0,0 +1,96 @@ +import { assert, describe, it } from "@effect/vitest"; +import { beforeEach } from "vite-plus/test"; + +import { + applyPendingDesktopProtocolUrl, + extractDesktopProtocolUrl, + loadDesktopProtocolUrl, + queuePendingDesktopProtocolUrl, + registerDesktopProtocolWindowLoader, + takePendingDesktopProtocolUrl, +} 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); + }); +}); + +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); + }); + + 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 new file mode 100644 index 000000000000..db2c2c34ee00 --- /dev/null +++ b/apps/desktop/src/app/desktopProtocolUrl.ts @@ -0,0 +1,67 @@ +// 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; +} + +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); +} + +// 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.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 42ba818acf5f..9a141bc5e3f2 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"; @@ -298,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); @@ -349,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), @@ -1036,6 +1053,68 @@ 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.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({ @@ -1101,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 9018b9b92c2a..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"; @@ -13,6 +14,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, + 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"; @@ -280,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()); @@ -611,12 +622,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; @@ -655,6 +672,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( @@ -734,6 +754,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" }); @@ -748,20 +772,29 @@ 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"); + yield* Effect.sync(() => { + applyPendingDesktopProtocolUrl(window); + }); 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; @@ -769,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.