diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..8bbe4a7f977 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"; @@ -221,6 +222,7 @@ const startup = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const clerk = yield* DesktopClerk.DesktopClerk; + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; @@ -239,6 +241,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; yield* clerk.configure; + // After clerk.configure, which owns the single-instance lock a second launch + // needs in order to hand its link to this process instead of starting its own. + yield* deepLinks.configure; yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), @@ -248,6 +253,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + // Nothing hands a pending deep link over here: bootstrap only requests the + // backend start, and the renderer is created later from handleBackendReady. + // The renderer claims the link itself once it mounts (takePendingDeepLink). yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopDeepLinks.test.ts b/apps/desktop/src/app/DesktopDeepLinks.test.ts new file mode 100644 index 00000000000..5dd1ea46b95 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.test.ts @@ -0,0 +1,423 @@ +import { assert, describe, it } from "@effect/vitest"; +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 Scope from "effect/Scope"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +interface ProtocolClientRegistration { + readonly scheme: string; + readonly path: string | undefined; + readonly args: ReadonlyArray | undefined; +} + +interface DeepLinkHarness { + readonly registrations: Array; + readonly dispatched: Array; + readonly listeners: Map) => void>; +} + +function makeHarness(): DeepLinkHarness { + return { registrations: [], dispatched: [], listeners: new Map() }; +} + +const makeElectronAppLayer = (harness: DeepLinkHarness) => + Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + 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), + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: (scheme, path, args) => + Effect.sync(() => { + harness.registrations.push({ scheme, path, args }); + return true; + }), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, + on: (eventName, listener) => + Effect.sync(() => { + harness.listeners.set(eventName, listener as (...args: ReadonlyArray) => void); + }), + } satisfies ElectronApp.ElectronApp["Service"]); + +// Reports delivery failure until the window "exists", mirroring how the real +// service drops renderer sends before bootstrap has opened a window. +const makeDesktopWindowLayer = (harness: DeepLinkHarness, hasRenderer: Ref.Ref) => + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + dispatchDeepLink: (target) => + Effect.gen(function* () { + if (!(yield* Ref.get(hasRenderer))) { + return false; + } + harness.dispatched.push(target); + return true; + }), + syncAppearance: Effect.void, + } as DesktopWindow.DesktopWindow["Service"]); + +const makeEnvironmentLayer = ( + overrides: Partial = {}, +) => + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + isDevelopment: false, + isPackaged: true, + appPath: "/opt/T3 Code/resources/app.asar", + ...overrides, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + +const withDeepLinks = ( + effect: Effect.Effect< + A, + E, + DesktopDeepLinks.DesktopDeepLinks | DesktopEnvironment.DesktopEnvironment | Scope.Scope + >, + input: { + readonly harness: DeepLinkHarness; + readonly hasRenderer: Ref.Ref; + readonly argv?: ReadonlyArray; + readonly environment?: Partial; + }, +) => { + const environmentLayer = makeEnvironmentLayer(input.environment); + const layer = Layer.effect( + DesktopDeepLinks.DesktopDeepLinks, + DesktopDeepLinks.make(input.argv ?? []), + ).pipe( + Layer.provideMerge(makeElectronAppLayer(input.harness)), + Layer.provideMerge(makeDesktopWindowLayer(input.harness, input.hasRenderer)), + Layer.provideMerge(environmentLayer), + ); + + return Effect.scoped(effect.pipe(Effect.provide(layer))); +}; + +describe("parseDeepLinkTarget", () => { + it("keeps the path, query, and fragment of a link to the app origin", () => { + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code://app/settings/connections", "t3code"), + Option.some("/settings/connections"), + ); + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code://app/threads?id=7#top", "t3code"), + Option.some("/threads?id=7#top"), + ); + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget(" t3code://app/ ", "t3code"), + Option.some("/"), + ); + }); + + it("refuses links that would navigate the renderer off its own origin", () => { + // A protocol-relative path is the dangerous one: a router reads + // "//evil.example" as another origin rather than an in-app route. + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code://app//evil.example/phish", "t3code"), + Option.none(), + ); + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code://evil.example/settings", "t3code"), + Option.none(), + ); + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("https://evil.example/settings", "t3code"), + Option.none(), + ); + }); + + it("keeps the development and production schemes apart", () => { + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code-dev://app/settings", "t3code"), + Option.none(), + ); + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget("t3code-dev://app/settings", "t3code-dev"), + Option.some("/settings"), + ); + }); + + it("ignores values that are not usable links", () => { + for (const rawUrl of [undefined, null, 42, "", " ", "not a url", "t3code:app/settings"]) { + assert.deepStrictEqual( + DesktopDeepLinks.parseDeepLinkTarget(rawUrl, "t3code"), + Option.none(), + `expected no target for ${String(rawUrl)}`, + ); + } + }); +}); + +describe("describeDeepLinkTarget", () => { + it("reduces a target to diagnostics that cannot leak a token", () => { + assert.deepStrictEqual( + DesktopDeepLinks.describeDeepLinkTarget("/pair?token=super-secret#frag"), + { path: "/pair", hasQuery: true, hasFragment: true }, + ); + assert.deepStrictEqual(DesktopDeepLinks.describeDeepLinkTarget("/settings"), { + path: "/settings", + hasQuery: false, + hasFragment: false, + }); + // A fragment can carry a credential too, so it must not survive either. + assert.deepStrictEqual(DesktopDeepLinks.describeDeepLinkTarget("/callback#id_token=abc"), { + path: "/callback", + hasQuery: false, + hasFragment: true, + }); + }); +}); + +describe("findDeepLinkTarget", () => { + it("picks the link out of the surrounding process arguments", () => { + assert.deepStrictEqual( + DesktopDeepLinks.findDeepLinkTarget( + ["/opt/T3 Code/t3code", "--no-sandbox", "t3code://app/settings", "--enable-logging"], + "t3code", + ), + Option.some("/settings"), + ); + }); + + it("resolves to none when no argument addresses the scheme", () => { + assert.deepStrictEqual( + DesktopDeepLinks.findDeepLinkTarget(["/opt/T3 Code/t3code", "--no-sandbox"], "t3code"), + Option.none(), + ); + }); +}); + +describe("DesktopDeepLinks", () => { + it.effect("claims the scheme on Linux and Windows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + }), + { harness, hasRenderer }, + ); + + assert.deepStrictEqual(harness.registrations, [ + { scheme: "t3code", path: undefined, args: undefined }, + ]); + }); + }); + + it.effect("points an unpackaged registration back at the Electron entry", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + }), + { + harness, + hasRenderer, + environment: { isPackaged: false, isDevelopment: true, appPath: "/repo/apps/desktop" }, + }, + ); + + assert.lengthOf(harness.registrations, 1); + const registration = harness.registrations[0]!; + assert.equal(registration.scheme, "t3code-dev"); + assert.equal(registration.path, process.execPath); + assert.deepStrictEqual(registration.args, ["/repo/apps/desktop"]); + }); + }); + + it.effect("leaves the scheme to the app bundle on macOS", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + }), + { harness, hasRenderer, environment: { platform: "darwin" } }, + ); + + assert.isEmpty(harness.registrations); + assert.isTrue(harness.listeners.has("open-url")); + }); + }); + + it.effect("hands a cold-start link to the renderer that asks for it", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + // No renderer exists while the app boots, which is exactly when a link + // handed over on the command line arrives, so the renderer claims it on + // mount instead of being pushed to. + const hasRenderer = yield* Ref.make(false); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + assert.isEmpty(harness.dispatched); + + assert.deepStrictEqual(yield* deepLinks.takePending, Option.some("/threads/42")); + // Claiming it clears it, so a remount does not navigate again. + assert.deepStrictEqual(yield* deepLinks.takePending, Option.none()); + }), + { + harness, + hasRenderer, + argv: ["/opt/T3 Code/t3code", "t3code://app/threads/42"], + }, + ); + }); + }); + + it.effect("keeps a link pending when no loaded renderer took it", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(false); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + + // Arrives while the app runs but before a renderer can take it, so + // the push fails and it must survive for the next renderer. + harness.listeners.get("second-instance")?.({}, [ + "/opt/T3 Code/t3code", + "t3code://app/settings", + ]); + yield* Effect.yieldNow; + + assert.isEmpty(harness.dispatched); + assert.deepStrictEqual(yield* deepLinks.takePending, Option.some("/settings")); + }), + { harness, hasRenderer }, + ); + }); + }); + + it.effect("stops holding a link once a renderer has it", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + + harness.listeners.get("second-instance")?.({}, [ + "/opt/T3 Code/t3code", + "t3code://app/settings", + ]); + yield* Effect.yieldNow; + + assert.deepStrictEqual(harness.dispatched, ["/settings"]); + // Pushed successfully, so a later mount has nothing to replay. + assert.deepStrictEqual(yield* deepLinks.takePending, Option.none()); + }), + { harness, hasRenderer }, + ); + }); + }); + + it.effect("routes a link handed over by a second launch", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + + const secondInstance = harness.listeners.get("second-instance"); + assert.isFunction(secondInstance); + secondInstance?.({}, ["/opt/T3 Code/t3code", "t3code://app/settings/connections"]); + yield* Effect.yieldNow; + + assert.deepStrictEqual(harness.dispatched, ["/settings/connections"]); + }), + { harness, hasRenderer }, + ); + }); + }); + + it.effect("routes a link delivered by macOS and consumes the event", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + + const openUrl = harness.listeners.get("open-url"); + assert.isFunction(openUrl); + let prevented = false; + openUrl?.( + { + preventDefault: () => { + prevented = true; + }, + }, + "t3code://app/settings", + ); + yield* Effect.yieldNow; + + assert.isTrue(prevented); + assert.deepStrictEqual(harness.dispatched, ["/settings"]); + }), + { harness, hasRenderer, environment: { platform: "darwin" } }, + ); + }); + }); + + it.effect("ignores a foreign link handed over by another app", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const hasRenderer = yield* Ref.make(true); + yield* withDeepLinks( + Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.configure; + + harness.listeners.get("open-url")?.({}, "https://evil.example/settings"); + harness.listeners.get("second-instance")?.({}, [ + "/opt/T3 Code/t3code", + "t3code://app//evil.example", + ]); + yield* Effect.yieldNow; + + assert.isEmpty(harness.dispatched); + }), + { harness, hasRenderer }, + ); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinks.ts b/apps/desktop/src/app/DesktopDeepLinks.ts new file mode 100644 index 00000000000..7e5ef417f9a --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.ts @@ -0,0 +1,123 @@ +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 Scope from "effect/Scope"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { getDesktopScheme } from "../electron/ElectronProtocol.ts"; +import { + describeDeepLinkTarget, + findDeepLinkTarget, + parseDeepLinkTarget, +} from "./deepLinkTarget.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export { describeDeepLinkTarget, findDeepLinkTarget, parseDeepLinkTarget }; + +const { logInfo: logDeepLinkInfo } = makeComponentLogger("desktop-deep-links"); + +export class DesktopDeepLinks extends Context.Service< + DesktopDeepLinks, + { + // Claims the scheme and starts listening. Runs before the app is ready so a + // cold-start link in argv is captured before anything can overwrite it. + readonly configure: Effect.Effect; + // Hands the pending link to the renderer that asks for it, clearing it in + // the same step. The renderer pulls on mount because a cold-start link is + // captured long before any renderer exists to be pushed to. + readonly takePending: Effect.Effect>; + } +>()("@t3tools/desktop/app/DesktopDeepLinks") {} + +export const make = (processArgv: ReadonlyArray = process.argv) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + const scheme = getDesktopScheme(environment.isDevelopment); + // One slot rather than a queue: these are navigation requests, so replaying + // a backlog would only flash through intermediate routes to land on the + // newest one anyway. The latest link wins. + const pendingTargetRef = yield* Ref.make(Option.none()); + + const takePending = Ref.getAndSet(pendingTargetRef, Option.none()).pipe( + Effect.withSpan("desktop.deepLinks.takePending"), + ); + + // Tries the renderer immediately for links that arrive while the app runs. + // Anything that cannot be handed over right now stays pending for the next + // renderer to pull, so nothing is dropped on the way. + const deliverPending = Effect.gen(function* () { + const pendingTarget = yield* Ref.get(pendingTargetRef); + if (Option.isNone(pendingTarget)) { + return; + } + + const delivered = yield* desktopWindow + .dispatchDeepLink(pendingTarget.value) + .pipe(Effect.orElseSucceed(() => false)); + if (delivered) { + // Compare before clearing: a newer link may have replaced this one + // while the send was in flight. + yield* Ref.update(pendingTargetRef, (current) => + Option.isSome(current) && current.value === pendingTarget.value ? Option.none() : current, + ); + } + }).pipe(Effect.withSpan("desktop.deepLinks.deliverPending")); + + const captureTarget = Effect.fn("desktop.deepLinks.captureTarget")(function* ( + target: Option.Option, + ) { + if (Option.isNone(target)) { + return; + } + + // A link is external input that can carry a token in its query or + // fragment, so only the route shape is logged. + yield* logDeepLinkInfo("deep link received", describeDeepLinkTarget(target.value)); + yield* Ref.set(pendingTargetRef, target); + yield* deliverPending; + }); + + const captureUrl = (rawUrl: unknown) => captureTarget(parseDeepLinkTarget(rawUrl, scheme)); + + const configure = Effect.gen(function* () { + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + // macOS routes links through the app object; Linux and Windows append them + // to argv, so those two need an explicit registration to be asked at all. + // NSIS never writes the association, so this is the only thing that + // registers the scheme on Windows. + if (environment.platform !== "darwin") { + yield* environment.isPackaged + ? electronApp.setAsDefaultProtocolClient(scheme) + : // An unpackaged run is `electron `, so the association has to + // point at the Electron binary and repeat the entry argument for the + // relaunched process to load anything. + electronApp.setAsDefaultProtocolClient(scheme, process.execPath, [environment.appPath]); + } + + yield* electronApp.on("open-url", (...args: ReadonlyArray) => { + const [event, url] = args as [{ preventDefault?: () => void } | undefined, unknown]; + event?.preventDefault?.(); + void runPromise(captureUrl(url)); + }); + + yield* electronApp.on("second-instance", (...args: ReadonlyArray) => { + const [, argv] = args as [unknown, ReadonlyArray | undefined]; + void runPromise(captureTarget(findDeepLinkTarget(argv ?? [], scheme))); + }); + + yield* Ref.set(pendingTargetRef, findDeepLinkTarget(processArgv, scheme)); + }).pipe(Effect.withSpan("desktop.deepLinks.configure")); + + return DesktopDeepLinks.of({ configure, takePending }); + }); + +export const layer = Layer.effect(DesktopDeepLinks, make()); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48..7798806d83f 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => { handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, + dispatchDeepLink: () => Effect.succeed(true), syncAppearance: Effect.void, }); diff --git a/apps/desktop/src/app/deepLinkTarget.ts b/apps/desktop/src/app/deepLinkTarget.ts new file mode 100644 index 00000000000..a78d2f971df --- /dev/null +++ b/apps/desktop/src/app/deepLinkTarget.ts @@ -0,0 +1,75 @@ +import * as Option from "effect/Option"; + +import { DESKTOP_HOST } from "../electron/ElectronProtocol.ts"; + +/** + * Extracts the in-app target from a deep link. + * + * A link is untrusted input from any web page, so this only ever yields a path + * within the renderer's own origin: a foreign scheme, a foreign host, or a + * protocol-relative path (which a router would read as another origin) all + * resolve to none rather than reaching the renderer. + */ +export function parseDeepLinkTarget(rawUrl: unknown, scheme: string): Option.Option { + if (typeof rawUrl !== "string") return Option.none(); + const trimmedUrl = rawUrl.trim(); + if (trimmedUrl.length === 0) return Option.none(); + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmedUrl); + } catch { + return Option.none(); + } + + if (parsedUrl.protocol !== `${scheme}:` || parsedUrl.host !== DESKTOP_HOST) { + return Option.none(); + } + + const target = `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + if (!target.startsWith("/") || target.startsWith("//")) { + return Option.none(); + } + + return Option.some(target); +} + +/** + * Reduces a target to bounded diagnostics. + * + * A link comes from outside the app and its query or fragment can carry a + * one-time token, so logs and spans record the route shape instead of the value. + */ +export function describeDeepLinkTarget(target: string): { + readonly path: string; + readonly hasQuery: boolean; + readonly hasFragment: boolean; +} { + const queryIndex = target.search(/[?#]/u); + return { + path: queryIndex === -1 ? target : target.slice(0, queryIndex), + hasQuery: target.includes("?"), + hasFragment: target.includes("#"), + }; +} + +/** + * Finds the deep link among process arguments. + * + * Linux and Windows deliver links as an argv entry rather than an event, mixed + * in with Chromium's own switches, so every argument gets tried and the first + * one addressing our scheme wins. + */ +export function findDeepLinkTarget( + argv: ReadonlyArray, + scheme: string, +): Option.Option { + for (const argument of argv) { + const target = parseDeepLinkTarget(argument, scheme); + if (Option.isSome(target)) { + return target; + } + } + + return Option.none(); +} diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 523e8764697..56d76a552e7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,6 +91,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + dispatchDeepLink: () => Effect.die("unexpected deep link"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..08e45a74d9d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -37,6 +37,7 @@ import { getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, + takePendingDeepLink, pickFolder, setTheme, showContextMenu, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(takePendingDeepLink); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..f753b2fc5df 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const DEEP_LINK_CHANNEL = "desktop:deep-link"; +export const TAKE_PENDING_DEEP_LINK_CHANNEL = "desktop:take-pending-deep-link"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..3465cf496f8 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; +import * as DesktopDeepLinks from "../../app/DesktopDeepLinks.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; @@ -259,6 +260,18 @@ export const showContextMenu = DesktopIpc.makeIpcMethod({ }), }); +// The renderer pulls on mount rather than only being pushed to: a link handed +// over on the command line is captured before any renderer exists. +export const takePendingDeepLink = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.TAKE_PENDING_DEEP_LINK_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.takePendingDeepLink")(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + return Option.getOrNull(yield* deepLinks.takePending); + }), +}); + export const openExternal = DesktopIpc.makeIpcMethod({ channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, payload: Schema.String, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ad370e36fdb..e8ca8a99fd6 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as 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"; @@ -181,6 +182,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopDeepLinks.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..8b99c70b7d7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + takePendingDeepLink: () => ipcRenderer.invoke(IpcChannels.TAKE_PENDING_DEEP_LINK_CHANNEL), + onDeepLink: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, target: unknown) => { + if (typeof target !== "string") return; + listener(target); + }; + + ipcRenderer.on(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 34fc4447146..38cbfec2084 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) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + dispatchDeepLink: () => Effect.succeed(true), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 40788009d3b..82195106a84 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -9,13 +9,18 @@ import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { describeDeepLinkTarget } from "../app/deepLinkTarget.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + DEEP_LINK_CHANNEL, + MENU_ACTION_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -80,6 +85,8 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Resolves false when no renderer was available to receive the link. + readonly dispatchDeepLink: (target: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -674,6 +681,40 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + // Pushes a renderer message and reveals the window it landed in. Reports + // whether the payload actually reached a loaded renderer: "deferred" only + // registers a did-finish-load listener, which never fires if the window is + // closed first, so a caller holding something it cannot regenerate must not + // treat that as delivered. + const sendToMainWindow = Effect.fn("desktop.window.sendToMainWindow")(function* ( + channel: string, + payload: string, + options: { readonly deferWhileLoading: boolean }, + ) { + const existingWindow = yield* focusedMainWindow; + if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) { + return "unavailable" as const; + } + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(channel, payload); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + if (!options.deferWhileLoading) { + return "unavailable" as const; + } + targetWindow.webContents.once("did-finish-load", send); + return "deferred" as const; + } + + send(); + return "sent" as const; + }); + const createMainIfBackendReady = Effect.gen(function* () { const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) return; @@ -768,24 +809,18 @@ export const make = Effect.gen(function* () { ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); - const existingWindow = yield* focusedMainWindow; - if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) { - return; - } - const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - void runPromise(electronWindow.reveal(targetWindow)); - }; - - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } - - send(); + yield* sendToMainWindow(MENU_ACTION_CHANNEL, action, { deferWhileLoading: true }); + }), + dispatchDeepLink: Effect.fn("desktop.window.dispatchDeepLink")(function* (target) { + // The target's query and fragment can carry a token, so the span records + // the route shape only. + yield* Effect.annotateCurrentSpan(describeDeepLinkTarget(target)); + // A renderer that is still loading pulls the link itself once it mounts, + // so there is nothing to gain from deferring the send here. + const delivery = yield* sendToMainWindow(DEEP_LINK_CHANNEL, target, { + deferWhileLoading: false, + }); + return delivery === "sent"; }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; diff --git a/apps/web/src/hooks/useDesktopDeepLinks.ts b/apps/web/src/hooks/useDesktopDeepLinks.ts new file mode 100644 index 00000000000..949917fa9f4 --- /dev/null +++ b/apps/web/src/hooks/useDesktopDeepLinks.ts @@ -0,0 +1,48 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +/** + * Routes `t3code://` links the desktop app hands over. + * + * Must be mounted by an always-rendered component: the desktop side pushes a + * link as soon as it arrives, and a listener that only exists on some routes + * would drop links that land while the pairing or sign-in screens are showing. + * + * Pulls once on mount as well, because a link handed over on the command line is + * captured before any renderer exists to receive a push. + */ +export function useDesktopDeepLinks(): void { + const navigate = useNavigate(); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge) { + return; + } + + let unmounted = false; + // `href` rather than `to`: a link carries a fully built path, and only the + // href form splits the query and fragment back out for the router. + const go = (target: string) => { + void navigate({ href: target }); + }; + + const takePendingDeepLink = bridge.takePendingDeepLink; + if (typeof takePendingDeepLink === "function") { + void takePendingDeepLink() + .then((target) => { + if (!unmounted && target !== null) { + go(target); + } + }) + .catch(() => undefined); + } + + const unsubscribe = bridge.onDeepLink?.(go); + + return () => { + unmounted = true; + unsubscribe?.(); + }; + }, [navigate]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..d14e0454357 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { useDesktopDeepLinks } from "../hooks/useDesktopDeepLinks"; import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; @@ -88,6 +89,10 @@ function RootRouteView() { const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + // Every branch below returns a different tree, so this has to sit above them: + // a deep link can arrive while the pairing or sign-in screen is showing. + useDesktopDeepLinks(); + useEffect(() => { const frame = window.requestAnimationFrame(() => { syncBrowserChromeTheme(); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d67..e2f8bb5f742 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1008,6 +1008,12 @@ export interface DesktopBridge { ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + // Receives the in-app path from a t3code:// link opened outside the app. The + // main process only ever forwards a path within the renderer's own origin. + onDeepLink?: (listener: (target: string) => void) => () => void; + // Claims a link that arrived before this renderer existed, such as one handed + // over on the command line at launch. Returns null when nothing is pending. + takePendingDeepLink?: () => Promise; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 64bb6c0617c..3012adcd5fb 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -16,6 +16,7 @@ import { DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, DESKTOP_EXTRA_RESOURCES, + DESKTOP_URL_PROTOCOLS, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, InvalidMockUpdateServerPortError, @@ -44,9 +45,16 @@ import { STAGE_INSTALL_ARGS, WINDOWS_ASAR_UNPACK, } from "./build-desktop-artifact.ts"; + import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +const PLATFORM_DEFAULT_TARGETS = { + mac: "dmg", + linux: "AppImage", + win: "nsis", +} as const; + function mockProcess(exitCode: number) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -511,7 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notInclude(error.message, secret); }); - it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => + it.effect("adds passkey entitlements to signed macOS builds", () => Effect.gen(function* () { const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, { entitlementsPath: "/tmp/entitlements.mac.plist", @@ -522,9 +530,38 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(config.appId, "com.t3tools.t3code"); assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); - assert.deepStrictEqual(mac.protocols, [ - { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, - ]); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + + // Declared once at the top level so the macOS Info.plist and the Linux + // desktop entry are both generated from it; a per-platform block would leave + // Linux with no x-scheme-handler and nothing for xdg to resolve. + it.effect("registers both renderer protocols on every platform", () => + Effect.gen(function* () { + for (const platform of ["mac", "linux", "win"] as const) { + const config = yield* createBuildConfig( + platform, + PLATFORM_DEFAULT_TARGETS[platform], + "1.2.3", + false, + false, + undefined, + undefined, + ); + + assert.deepStrictEqual( + config.protocols, + DESKTOP_URL_PROTOCOLS, + `expected renderer protocols for ${platform}`, + ); + assert.notProperty(config.mac ?? {}, "protocols"); + assert.notProperty(config.linux ?? {}, "protocols"); + } + + // Only the scheme a packaged build answers to: it resolves t3code from + // isDevelopment being false, so declaring t3code-dev would advertise a + // handler that rejects those links and would outrank a dev run. + assert.deepStrictEqual(DESKTOP_URL_PROTOCOLS, [{ name: "T3 Code", schemes: ["t3code"] }]); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9687e76a1dc..ca2427ffe1d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -640,6 +640,17 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // The Windows primary backend reads the same files through the asar redirect, // so nothing is duplicated. export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; +// Only the production scheme. A packaged artifact resolves its scheme from +// isDevelopment, which is false once packaged, so it would reject t3code-dev +// links after launch — and declaring that scheme anyway would let an installed +// build take the association away from a dev run. Dev runs are unpackaged and +// claim t3code-dev at runtime instead (see DesktopDeepLinks). +export const DESKTOP_URL_PROTOCOLS = [ + { + name: "T3 Code", + schemes: ["t3code"], + }, +] as const; export const DESKTOP_EXTRA_RESOURCES = [ { from: "apps/desktop/prod-resources/resource-monitor", @@ -1549,6 +1560,11 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // extracts native libraries, which fff-node finds in app.asar.unpacked. ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), extraResources: DESKTOP_EXTRA_RESOURCES, + // Declared at the top level, not per-platform: electron-builder feeds this + // to the macOS Info.plist and the Linux desktop entry's MimeType, and the + // Linux desktop entry is what makes xdg resolve t3code:// to the app. NSIS + // ignores it, so Windows registers the scheme at runtime instead. + protocols: DESKTOP_URL_PROTOCOLS, }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1568,12 +1584,6 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", - protocols: [ - { - name: "T3 Code", - schemes: ["t3code", "t3code-dev"], - }, - ], ...(macPasskeySigning ? { entitlements: macPasskeySigning.entitlementsPath,