diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts new file mode 100644 index 00000000000..9e46b137b0b --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Option from "effect/Option"; + +import { resolveDesktopBackendPortHint } from "./DesktopApp.ts"; + +describe("resolveDesktopBackendPortHint", () => { + it("keeps the renderer protocol aligned with a reused live backend", () => { + expect(resolveDesktopBackendPortHint("http://127.0.0.1:8080/", Option.some(3773))).toEqual( + Option.some(8080), + ); + }); + + it("uses the configured port when no live backend exists", () => { + expect(resolveDesktopBackendPortHint(undefined, Option.some(4949))).toEqual(Option.some(4949)); + }); + + it("ignores a malformed live backend marker", () => { + expect(resolveDesktopBackendPortHint("not a URL", Option.some(4949))).toEqual( + Option.some(4949), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..5d09a5c9446 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -20,6 +21,7 @@ import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import { readLiveExistingBackend } from "../backend/DesktopExistingBackend.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -63,6 +65,24 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); +export function resolveDesktopBackendPortHint( + existingBackendHttpBaseUrl: string | undefined, + configuredPort: Option.Option, +): Option.Option { + if (existingBackendHttpBaseUrl !== undefined) { + try { + const port = Number.parseInt(new URL(existingBackendHttpBaseUrl).port, 10); + if (Number.isSafeInteger(port) && port > 0 && port <= MAX_TCP_PORT) { + return Option.some(port); + } + } catch { + // Fall through to the configured port when the live marker is malformed. + } + } + + return configuredPort; +} + const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, ) { @@ -151,7 +171,10 @@ const bootstrap = Effect.gen(function* () { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const existingBackend = readLiveExistingBackend(environment.stateDir); + const backendPortSelection = yield* resolveDesktopBackendPort( + resolveDesktopBackendPortHint(existingBackend?.httpBaseUrl, environment.configuredBackendPort), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan @@ -162,6 +185,13 @@ const bootstrap = Effect.gen(function* () { ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), }, ); + if (existingBackend) { + yield* logBootstrapInfo("reusing existing backend for shared T3 home", { + pid: existingBackend.pid, + httpBaseUrl: existingBackend.httpBaseUrl, + stateDir: existingBackend.stateDir, + }); + } const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { @@ -213,6 +243,12 @@ const bootstrap = Effect.gen(function* () { // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); } + + // Catalog + window services are usable; flush any deep link captured from + // initial argv / open-url during single-instance setup. + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* logBootstrapInfo("bootstrap deep links ready"); }).pipe(Effect.withSpan("desktop.bootstrap")); const startup = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..2e1058b878a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -22,13 +23,16 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, isPackaged = false) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + isPackaged, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return DesktopClerk.layer.pipe( @@ -146,4 +150,150 @@ describe("DesktopClerk", () => { storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); + + it.effect( + "wires second-instance argv into deep links and registers the protocol when packaged", + () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + const handledArgv = yield* Ref.make>([]); + const handledUrls = yield* Ref.make([]); + const listeners = new Map void>(); + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: (argv) => + Ref.update(handledArgv, (items) => [...items, argv]).pipe(Effect.asVoid), + handleUrl: (url) => + Ref.update(handledUrls, (items) => [...items, url]).pipe(Effect.asVoid), + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: (protocol: string) => + Effect.sync(() => { + protocolClientRegistered = protocol === "t3code"; + return true; + }), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: >( + eventName: string, + listener: (...args: Args) => void, + ) => + Effect.sync(() => { + listeners.set(eventName, listener as (...args: readonly unknown[]) => void); + }).pipe(Effect.asVoid), + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(false, true), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + assert.isTrue(protocolClientRegistered); + assert.isTrue(listeners.has("second-instance")); + assert.isTrue(listeners.has("open-url")); + + // Initial process.argv is captured during configure. + const initialHandled = yield* Ref.get(handledArgv); + assert.isTrue(initialHandled.length >= 1); + + const secondInstance = listeners.get("second-instance"); + assert.isDefined(secondInstance); + secondInstance?.({}, [ + "t3code", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + // Allow the fire-and-forget runPromise callback to settle. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const afterSecond = yield* Ref.get(handledArgv); + assert.isTrue( + afterSecond.some((argv) => + argv.some((entry) => entry.startsWith("t3code://open/thread")), + ), + ); + + const openUrl = listeners.get("open-url"); + assert.isDefined(openUrl); + const preventDefault = vi.fn(); + openUrl?.( + { preventDefault }, + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.equal(preventDefault.mock.calls.length, 1); + const urls = yield* Ref.get(handledUrls); + assert.deepEqual(urls, [ + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }, + ); + + it.effect("does not register the OS protocol client in development", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: () => Effect.void, + handleUrl: () => Effect.void, + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + requestSingleInstanceLock: Effect.succeed(true), + setAsDefaultProtocolClient: () => + Effect.sync(() => { + protocolClientRegistered = true; + return true; + }), + on: () => Effect.void, + quit: Effect.void, + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(true, false), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + assert.isFalse(protocolClientRegistered); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..8aa1fac2a94 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -3,14 +3,13 @@ import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; 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 DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -47,7 +46,7 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + DesktopDeepLinks.DesktopDeepLinks | ElectronApp.ElectronApp | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -109,8 +108,9 @@ export const make = Effect.gen(function* () { return DesktopClerk.of({ configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + // Capture ambient services for Electron event callbacks, which cannot yield. + const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); if (!(yield* electronApp.requestSingleInstanceLock)) { @@ -118,16 +118,27 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); + // Register before readiness so cold-start and second-instance deep links + // are not dropped. Deep-link processing itself queues until start(). + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[] = []) => { + void runPromise(deepLinks.handleArgv(argv)); + }); + + // macOS delivers custom URL scheme activations through open-url. + yield* electronApp.on("open-url", (event: { preventDefault?: () => void }, url: string) => { + event.preventDefault?.(); + void runPromise(deepLinks.handleUrl(url)); }); + + // Packaged builds own the OS protocol handler. Skip in development so a + // local electron binary does not replace the installed t3code handler. + if (environment.isPackaged && !environment.isDevelopment) { + yield* electronApp.setAsDefaultProtocolClient(DesktopDeepLinks.DESKTOP_EXTERNAL_PROTOCOL); + } + + // Initial argv may already contain a deep link (direct CLI invocation or + // protocol launch on Linux/Windows). + yield* deepLinks.handleArgv(process.argv); }).pipe(Effect.withSpan("desktop.clerk.configure")), }); }); diff --git a/apps/desktop/src/app/DesktopDeepLinks.test.ts b/apps/desktop/src/app/DesktopDeepLinks.test.ts new file mode 100644 index 00000000000..b479daa319d --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.test.ts @@ -0,0 +1,310 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { vi } from "vite-plus/test"; + +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; + +const THREAD_ID = ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"); +const ENVIRONMENT_ID = EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"); +const VALID_DEEP_LINK = + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463"; + +function catalogJson(targets: readonly { label: string; environmentId: string }[]): string { + return JSON.stringify({ + schemaVersion: 1, + targets: targets.map((target) => ({ + _tag: "BearerConnectionTarget", + environmentId: target.environmentId, + label: target.label, + connectionId: `bearer:${target.environmentId}`, + })), + profiles: [], + credentials: [], + remoteDpopTokens: [], + }); +} + +describe("DesktopDeepLinks parsing", () => { + it("parses a valid thread deep link from mixed argv", () => { + const parsed = DesktopDeepLinks.findDesktopThreadDeepLinkInArgv([ + "/usr/bin/t3code", + "--enable-features=Foo", + VALID_DEEP_LINK, + "--some-flag", + ]); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3vm"); + assert.equal( + parsed._tag === "Some" ? parsed.value.threadId : null, + "ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); + + it("percent-decodes the connection label exactly once", () => { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink( + "t3code://open/thread?connection=t3%2Fvm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3/vm"); + }); + + it("parses URL-like environment hosts in short and FQDN forms", () => { + for (const [raw, expectedLabel] of [ + ["t3code://t3vm?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm"], + ["t3code://t3vm.long.host?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm.long.host"], + ] as const) { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink(raw); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, expectedLabel); + } + }); + + it("rejects wrong schemes, hosts, paths, missing/duplicate values, controls, and oversized values", () => { + const rejects = [ + "https://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://close/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/settings?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm", + "t3code://open/thread?connection=t3vm&connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=a&thread=b", + "t3code://t3vm?connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://t3vm/path?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463\u0000", + `t3code://open/thread?connection=${"x".repeat(300)}&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463`, + "not-a-url", + "t3code://app/", + ]; + for (const raw of rejects) { + assert.isTrue( + Option.isNone(DesktopDeepLinks.parseDesktopThreadDeepLink(raw)), + `expected rejection for ${JSON.stringify(raw)}`, + ); + } + }); + + it("builds a hash-history navigation URL on the desktop origin", () => { + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: false, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: true, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); +}); + +describe("DesktopDeepLinks catalog resolution", () => { + it("resolves a unique connection label to its environment id", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "other", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "db6d1813-ace4-42bd-9bce-e04ee27e97ff"); + } + }); + + it("resolves short and fully qualified host labels in either direction", () => { + const fqdnTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + ]), + "t3vm", + ); + assert.equal(fqdnTarget._tag, "resolved"); + + const shortTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "t3vm.long.host", + ); + assert.equal(shortTarget._tag, "resolved"); + }); + + it("prefers an exact configured label over a short-host fallback", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + }); + + it("keeps ambiguous short matches ambiguous and does not equate distinct FQDNs", () => { + const catalog = catalogJson([ + { label: "t3vm.one.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm.two.host", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm")._tag, + "ambiguous", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm.three.host")._tag, + "missing", + ); + }); + + it("refuses missing and duplicate labels", () => { + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "other", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }]), + "t3vm", + )._tag, + "missing", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + )._tag, + "ambiguous", + ); + }); +}); + +describe("DesktopDeepLinks service delivery", () => { + const makeHarness = Effect.gen(function* () { + const navigations = yield* Ref.make>([]); + const activations = yield* Ref.make(0); + const catalogJsonRef = yield* Ref.make( + Option.some( + catalogJson([ + { + label: "t3vm", + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + }, + ]), + ), + ); + + const windowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Ref.update(activations, (count) => count + 1), + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + navigateToThread: (input: { readonly environmentId: string; readonly threadId: string }) => + Ref.update(navigations, (items) => [ + ...items, + { environmentId: input.environmentId, threadId: input.threadId }, + ]), + } as unknown as DesktopWindow.DesktopWindow["Service"]); + + const catalogLayer = Layer.succeed( + DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, + { + get: Ref.get(catalogJsonRef), + set: () => Effect.succeed(true), + clear: Effect.void, + } satisfies DesktopConnectionCatalogStore.DesktopConnectionCatalogStore["Service"], + ); + + const layer = DesktopDeepLinks.layer.pipe( + Layer.provide(windowLayer), + Layer.provide(catalogLayer), + ); + + return { layer, navigations, activations, catalogJsonRef } as const; + }); + + it.effect("queues initial-launch delivery until start, then opens the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.handleArgv(["t3code", VALID_DEEP_LINK]); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + yield* deepLinks.start; + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "ebf3a84d-7f60-4809-a5e0-bbd574275463", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("delivers running-instance deep links through handleArgv after start", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("preserves reveal-only behavior for ordinary second launches", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv(["t3code"]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("reveals without navigating when the connection label is missing", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=missing&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinks.ts b/apps/desktop/src/app/DesktopDeepLinks.ts new file mode 100644 index 00000000000..767ee063abb --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.ts @@ -0,0 +1,347 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { buildDesktopThreadNavigationUrl } from "../electron/ElectronProtocol.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export { buildDesktopThreadNavigationUrl }; + +export const DESKTOP_EXTERNAL_PROTOCOL = "t3code"; +export const DESKTOP_THREAD_DEEP_LINK_HOST = "open"; +export const DESKTOP_THREAD_DEEP_LINK_PATH = "/thread"; + +/** Reject oversized query values (connection labels and thread ids). */ +const MAX_DEEP_LINK_VALUE_LENGTH = 256; + +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +export type DesktopThreadDeepLink = { + readonly connectionLabel: string; + readonly threadId: ThreadId; +}; + +export type DesktopConnectionLabelResolution = + | { readonly _tag: "resolved"; readonly environmentId: EnvironmentId } + | { readonly _tag: "missing" } + | { readonly _tag: "ambiguous" } + | { readonly _tag: "invalidCatalog"; readonly cause: unknown }; + +type PendingDeepLinkAction = + | { readonly _tag: "reveal" } + | { readonly _tag: "openThread"; readonly deepLink: DesktopThreadDeepLink }; + +const { logInfo: logDeepLinkInfo, logWarning: logDeepLinkWarning } = + makeComponentLogger("desktop-deep-links"); + +/** + * Parses a desktop thread deep link in either explicit or host shorthand form: + * + * - `t3code://open/thread?connection=&thread=` + * - `t3code://?thread=` + */ +export function parseDesktopThreadDeepLink(raw: string): Option.Option { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 2_048) { + return Option.none(); + } + if (CONTROL_CHARACTER_PATTERN.test(raw)) { + return Option.none(); + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return Option.none(); + } + + if (url.protocol !== `${DESKTOP_EXTERNAL_PROTOCOL}:`) { + return Option.none(); + } + // Normalize trailing slashes so `/thread` and `/thread/` both work. + const pathname = + url.pathname.length > 1 && url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + const connectionValues = url.searchParams.getAll("connection"); + const threadValues = url.searchParams.getAll("thread"); + if (threadValues.length !== 1) { + return Option.none(); + } + + const isExplicitForm = + url.hostname === DESKTOP_THREAD_DEEP_LINK_HOST && pathname === DESKTOP_THREAD_DEEP_LINK_PATH; + const isHostShorthand = (pathname === "" || pathname === "/") && url.hostname.length > 0; + if ( + (!isExplicitForm && !isHostShorthand) || + (isExplicitForm && connectionValues.length !== 1) || + (isHostShorthand && connectionValues.length !== 0) + ) { + return Option.none(); + } + + // URLSearchParams percent-decodes once; reject empties, controls, and oversized values. + const connectionLabel = isExplicitForm ? (connectionValues[0] ?? "") : url.hostname; + const threadRaw = threadValues[0] ?? ""; + if ( + connectionLabel.length === 0 || + threadRaw.length === 0 || + connectionLabel.length > MAX_DEEP_LINK_VALUE_LENGTH || + threadRaw.length > MAX_DEEP_LINK_VALUE_LENGTH || + CONTROL_CHARACTER_PATTERN.test(connectionLabel) || + CONTROL_CHARACTER_PATTERN.test(threadRaw) + ) { + return Option.none(); + } + + let threadId: ThreadId; + try { + threadId = Schema.decodeUnknownSync(ThreadId)(threadRaw); + } catch { + return Option.none(); + } + + return Option.some({ + connectionLabel, + threadId, + }); +} + +/** + * Scans argv for the first valid thread deep link. Electron may inject + * platform-specific flags, so the URL is not assumed to be at a fixed index. + */ +export function findDesktopThreadDeepLinkInArgv( + argv: readonly string[], +): Option.Option { + for (const entry of argv) { + const parsed = parseDesktopThreadDeepLink(entry); + if (Option.isSome(parsed)) { + return parsed; + } + } + return Option.none(); +} + +function isShortAndFqdnMatch(left: string, right: string): boolean { + const leftIsFqdn = left.includes("."); + const rightIsFqdn = right.includes("."); + if (leftIsFqdn === rightIsFqdn) { + return false; + } + const shortLabel = leftIsFqdn ? right : left; + const fqdnLabel = leftIsFqdn ? left : right; + return fqdnLabel.slice(0, fqdnLabel.indexOf(".")) === shortLabel; +} + +/** + * Resolves a configured deep-link connection label to a unique environment id. + * + * The link producer's configured override is already represented by + * `connectionLabel`. Exact catalog labels win; only a missing exact match may + * fall back to matching a short host label with its FQDN form. + */ +export function resolveEnvironmentIdForConnectionLabel( + catalogJson: string, + connectionLabel: string, +): DesktopConnectionLabelResolution { + let document: ConnectionCatalogDocumentType; + try { + document = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument))( + catalogJson, + ); + } catch (cause) { + return { _tag: "invalidCatalog", cause }; + } + + const exactMatches = document.targets.filter((target) => target.label === connectionLabel); + const matches = + exactMatches.length > 0 + ? exactMatches + : document.targets.filter((target) => isShortAndFqdnMatch(target.label, connectionLabel)); + if (matches.length === 0) { + return { _tag: "missing" }; + } + if (matches.length > 1) { + return { _tag: "ambiguous" }; + } + return { + _tag: "resolved", + environmentId: matches[0]!.environmentId, + }; +} + +export class DesktopDeepLinks extends Context.Service< + DesktopDeepLinks, + { + /** + * Handle a launch argv (initial process.argv or second-instance commandLine). + * Queues until `start` if the navigation services are not ready yet. + */ + readonly handleArgv: (argv: readonly string[]) => Effect.Effect; + /** + * Handle a macOS `open-url` deep link. + */ + readonly handleUrl: (url: string) => Effect.Effect; + /** + * Begin processing deep links. Flushes any action queued before readiness. + * Newest queued action wins. + */ + readonly start: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinks") {} + +export const make = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const catalogStore = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const startedRef = yield* Ref.make(false); + const pendingActionRef = yield* Ref.make>(Option.none()); + + const revealOnly = desktopWindow.activate.pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to reveal desktop window for deep link", { + message: error.message, + }), + ), + Effect.asVoid, + ); + + const resolveDeepLinkFromCatalog = ( + deepLink: DesktopThreadDeepLink, + ): Effect.Effect => + catalogStore.get.pipe( + Effect.catch((cause) => + logDeepLinkWarning("connection catalog unavailable for deep link resolution", { + reason: cause._tag, + }).pipe(Effect.as(Option.none())), + ), + Effect.map((catalog) => { + if (Option.isNone(catalog)) { + return { _tag: "missing" } as const; + } + return resolveEnvironmentIdForConnectionLabel(catalog.value, deepLink.connectionLabel); + }), + ); + + const openResolvedThread = (deepLink: DesktopThreadDeepLink): Effect.Effect => + Effect.gen(function* () { + const resolution = yield* resolveDeepLinkFromCatalog(deepLink); + switch (resolution._tag) { + case "resolved": { + yield* logDeepLinkInfo("opening thread from deep link", { + connectionLabel: deepLink.connectionLabel, + // Log only that an environment was resolved — never catalog contents. + resolved: true, + }); + yield* desktopWindow + .navigateToThread({ + environmentId: resolution.environmentId, + threadId: deepLink.threadId, + }) + .pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to navigate to deep-linked thread", { + message: error.message, + }), + ), + ); + return; + } + case "missing": { + yield* logDeepLinkWarning("deep link connection label not found", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "ambiguous": { + yield* logDeepLinkWarning("deep link connection label is ambiguous", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "invalidCatalog": { + yield* logDeepLinkWarning("deep link connection catalog could not be decoded", { + reason: "invalidCatalog", + }); + yield* revealOnly; + return; + } + } + }).pipe(Effect.withSpan("desktop.deepLinks.openResolvedThread")); + + const processAction = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + switch (action._tag) { + case "reveal": + yield* revealOnly; + return; + case "openThread": + yield* openResolvedThread(action.deepLink); + return; + } + }).pipe(Effect.withSpan("desktop.deepLinks.processAction")); + + const enqueueOrProcess = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef); + if (!started) { + // Newest action wins while startup is still settling. + yield* Ref.set(pendingActionRef, Option.some(action)); + return; + } + yield* processAction(action); + }); + + const handleArgv = (argv: readonly string[]): Effect.Effect => + Effect.gen(function* () { + const deepLink = findDesktopThreadDeepLinkInArgv(argv); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleArgv")); + + const handleUrl = (url: string): Effect.Effect => + Effect.gen(function* () { + const deepLink = parseDesktopThreadDeepLink(url); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* logDeepLinkWarning("ignored unsupported open-url payload"); + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleUrl")); + + const start = Effect.gen(function* () { + const alreadyStarted = yield* Ref.getAndSet(startedRef, true); + if (alreadyStarted) { + return; + } + const pending = yield* Ref.getAndSet(pendingActionRef, Option.none()); + if (Option.isSome(pending)) { + yield* processAction(pending.value); + } + }).pipe(Effect.withSpan("desktop.deepLinks.start")); + + return DesktopDeepLinks.of({ + handleArgv, + handleUrl, + start, + }); +}); + +export const layer = Layer.effect(DesktopDeepLinks, make); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 9aaac689c34..c345cb8d886 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -86,6 +86,7 @@ function makePoolLayer( flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), ), diff --git a/apps/desktop/src/electron/DesktopEarlyStartup.test.ts b/apps/desktop/src/electron/DesktopEarlyStartup.test.ts new file mode 100644 index 00000000000..56ccc2116f0 --- /dev/null +++ b/apps/desktop/src/electron/DesktopEarlyStartup.test.ts @@ -0,0 +1,53 @@ +import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +import { + type DesktopEarlyStartupApp, + configureDesktopEarlyStartup, +} from "./DesktopEarlyStartup.ts"; + +function makeApp() { + const exit = vi.fn(); + const getVersion = vi.fn(() => "1.2.3"); + const app: DesktopEarlyStartupApp = { + exit, + getVersion, + }; + + return { app, exit, getVersion }; +} + +describe("configureDesktopEarlyStartup", () => { + it.each(["--version", "-v"] as const)( + "prints the packaged version for %s", + (flag: "--version" | "-v") => { + const { app, exit, getVersion } = makeApp(); + const writeStdout = vi.fn(); + + configureDesktopEarlyStartup({ + app, + argv: ["t3code", flag], + writeStdout, + }); + + assert.strictEqual(getVersion.mock.calls.length, 1); + assert.deepStrictEqual(writeStdout.mock.calls, [["1.2.3\n"]]); + assert.deepStrictEqual(exit.mock.calls, [[0]]); + }, + ); + + it("does nothing for an ordinary launch", () => { + const { app, exit, getVersion } = makeApp(); + const writeStdout = vi.fn(); + + configureDesktopEarlyStartup({ + app, + argv: ["t3code"], + writeStdout, + }); + + assert.strictEqual(getVersion.mock.calls.length, 0); + assert.strictEqual(writeStdout.mock.calls.length, 0); + assert.strictEqual(exit.mock.calls.length, 0); + }); +}); diff --git a/apps/desktop/src/electron/DesktopEarlyStartup.ts b/apps/desktop/src/electron/DesktopEarlyStartup.ts new file mode 100644 index 00000000000..b56ad1a91ea --- /dev/null +++ b/apps/desktop/src/electron/DesktopEarlyStartup.ts @@ -0,0 +1,27 @@ +export interface DesktopEarlyStartupApp { + readonly exit: (exitCode?: number) => void; + readonly getVersion: () => string; +} + +export interface ConfigureDesktopEarlyStartupOptions { + readonly app: DesktopEarlyStartupApp; + readonly argv: ReadonlyArray; + readonly writeStdout: (value: string) => unknown; +} + +/** + * Applies command-line behavior that Electron must receive before `app.whenReady()`. + */ +export function configureDesktopEarlyStartup({ + app, + argv, + writeStdout, +}: ConfigureDesktopEarlyStartupOptions): void { + if (argv.includes("--version") || argv.includes("-v")) { + try { + writeStdout(`${app.getVersion()}\n`); + } finally { + app.exit(0); + } + } +} diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..3ae1f99607b 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -24,6 +24,22 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } +/** + * Builds the desktop renderer URL for a canonical thread route. + * + * The Electron client uses hash history, so the path is carried after `#/`. + */ +export function buildDesktopThreadNavigationUrl(input: { + readonly isDevelopment: boolean; + readonly environmentId: string; + readonly threadId: string; +}): string { + const origin = getDesktopOrigin(input.isDevelopment); + const environmentSegment = encodeURIComponent(input.environmentId); + const threadSegment = encodeURIComponent(input.threadId); + return `${origin}/#/${environmentSegment}/${threadSegment}`; +} + export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 26e5d3ac412..01b476f537b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; +import { configureDesktopEarlyStartup } from "./electron/DesktopEarlyStartup.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -35,6 +36,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 DesktopDeepLinks from "./app/DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -61,6 +63,12 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +configureDesktopEarlyStartup({ + app: Electron.app, + argv: process.argv, + writeStdout: (value) => process.stdout.write(value), +}); + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -153,6 +161,11 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopDeepLinksLayer = DesktopDeepLinks.layer.pipe( + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(desktopFoundationLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -183,6 +196,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopOpenWith.layer, desktopSshLayer, ).pipe( + Layer.provideMerge(desktopDeepLinksLayer), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 149caa79747..139777e4b99 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -80,6 +80,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cd06cfcf32..ceafa21b3fa 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -32,6 +32,8 @@ vi.mock("electron", async (importOriginal) => ({ }, })); +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -1098,4 +1100,64 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(scenario.layer)); }), ); + + it.effect("queues navigation before the main window is ready and applies it on create", () => + 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.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + assert.equal(yield* Ref.get(createCount), 0); + assert.equal(fakeWindow.loadURL.mock.calls.length, 0); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates an existing main window to the encoded canonical route", () => + 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")); + fakeWindow.loadURL.mockClear(); + + yield* desktopWindow.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ], + ]); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 40788009d3b..735ca511cbf 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -11,7 +12,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; +import { buildDesktopThreadNavigationUrl, getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -81,9 +82,24 @@ export class DesktopWindow extends Context.Service< readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; + /** + * Navigate the main window to the canonical thread route + * (`#/$environmentId/$threadId`). Reuses or creates the main window, + * preserves backend-readiness/splash behavior, and retains one pending + * target when the renderer is not yet navigable (newest wins). + */ + readonly navigateToThread: (input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + }) => Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} +export type DesktopPendingThreadRoute = { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}; + const { logInfo: logWindowInfo, logWarning: logWindowWarning } = makeComponentLogger("desktop-window"); @@ -255,11 +271,57 @@ export const make = Effect.gen(function* () { // 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()); + // Single pending deep-link route. Newest wins when several arrive during + // startup or while the main window is still loading. + const pendingThreadRouteRef = yield* Ref.make>( + Option.none(), + ); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + const loadThreadRouteOnWindow = (window: Electron.BrowserWindow): void => { + if (window.isDestroyed()) { + return; + } + const apply = () => { + void runPromise( + Effect.gen(function* () { + if (window.isDestroyed()) { + return; + } + const pending = yield* Ref.get(pendingThreadRouteRef); + if (Option.isNone(pending)) { + return; + } + const url = buildDesktopThreadNavigationUrl({ + isDevelopment: environment.isDevelopment, + environmentId: pending.value.environmentId, + threadId: pending.value.threadId, + }); + void window.loadURL(url).catch(() => undefined); + // Clear only the route we applied so a newer pending target is kept. + yield* Ref.update(pendingThreadRouteRef, (current) => { + if ( + Option.isSome(current) && + current.value.environmentId === pending.value.environmentId && + current.value.threadId === pending.value.threadId + ) { + return Option.none(); + } + return current; + }); + }), + ); + }; + if (window.webContents.isLoadingMainFrame()) { + window.webContents.once("did-finish-load", apply); + return; + } + apply(); + }; + const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { @@ -549,7 +611,37 @@ export const make = Effect.gen(function* () { if (window.isDestroyed()) { return; } - void window.loadURL(applicationUrl).catch(() => undefined); + void runPromise( + Effect.gen(function* () { + const pending = yield* Ref.get(pendingThreadRouteRef); + const url = Option.isSome(pending) + ? buildDesktopThreadNavigationUrl({ + isDevelopment: environment.isDevelopment, + environmentId: pending.value.environmentId, + threadId: pending.value.threadId, + }) + : applicationUrl; + if (window.isDestroyed()) { + return; + } + void window.loadURL(url).catch(() => undefined); + if (Option.isNone(pending)) { + return; + } + // Drop only the route we just loaded. A newer deep link that arrived + // after we read pending must stay queued. + yield* Ref.update(pendingThreadRouteRef, (current) => { + if ( + Option.isSome(current) && + current.value.environmentId === pending.value.environmentId && + current.value.threadId === pending.value.threadId + ) { + return Option.none(); + } + return current; + }); + }), + ); }; const scheduleDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { @@ -793,6 +885,44 @@ export const make = Effect.gen(function* () { syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), + navigateToThread: Effect.fn("desktop.window.navigateToThread")(function* (input) { + yield* Effect.annotateCurrentSpan({ + environmentId: input.environmentId, + threadId: input.threadId, + }); + // Newest deep link wins if several arrive before navigation applies. + yield* Ref.set( + pendingThreadRouteRef, + Option.some({ + environmentId: input.environmentId, + threadId: input.threadId, + }), + ); + + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + const window = existingWindow.value; + loadThreadRouteOnWindow(window); + yield* electronWindow.reveal(window); + yield* logWindowInfo("navigated main window to deep-linked thread"); + return; + } + + // No real main window yet. Keep the pending route so createWindow's + // initial load uses the thread URL. Create immediately when the backend + // is ready; otherwise handleBackendReady / createMainIfBackendReady will + // open the window and pick up the pending target. + yield* createMainIfBackendReady; + const createdWindow = yield* currentMainWindow; + if (Option.isSome(createdWindow)) { + // createWindow already scheduled the pending URL via loadApplication. + yield* electronWindow.reveal(createdWindow.value); + yield* logWindowInfo("created main window for deep-linked thread"); + return; + } + + yield* logWindowInfo("queued deep-linked thread until main window is navigable"); + }), }); }); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 54f6482e9b4..d059c5c4d16 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1482,8 +1482,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( desktop: { entry: { StartupWMClass: "t3code", + // Register the external deep-link scheme so xdg-open can launch T3. + // electron-builder keeps %U on Exec when MimeType is present. + MimeType: "x-scheme-handler/t3code;", }, }, + protocols: [ + { + name: "T3 Code", + schemes: ["t3code"], + }, + ], }; }