diff --git a/t3code/apps/desktop/src/app/DesktopApp.ts b/t3code/apps/desktop/src/app/DesktopApp.ts index b98175529..753da00a2 100644 --- a/t3code/apps/desktop/src/app/DesktopApp.ts +++ b/t3code/apps/desktop/src/app/DesktopApp.ts @@ -6,6 +6,7 @@ import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as NetService from "@t3tools/shared/Net"; +import * as ElectronDeepLink from "../electron/ElectronDeepLink.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -215,6 +216,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* electronProtocol.registerDesktopFileProtocol; + yield* Effect.service(ElectronDeepLink.ElectronDeepLink).pipe( + Effect.flatMap((deepLink) => deepLink.register), + ); yield* updates.configure; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/t3code/apps/desktop/src/electron/.contributor.json b/t3code/apps/desktop/src/electron/.contributor.json new file mode 100644 index 000000000..c86bb3512 --- /dev/null +++ b/t3code/apps/desktop/src/electron/.contributor.json @@ -0,0 +1 @@ +{"agent": "Hermes Agent", "initialized_with": "Add deep linking support to the Electron desktop app by registering a custom t3code:// protocol, enabling navigation from external sources (e.g., browser links) to specific projects, chat threads, or settings. URL patterns: t3code://open/project?path=/path/to/repo, t3code://chat/thread?id=abc123, t3code://settings. Must register protocol handler, parse URLs and route via IPC, handle app lifecycle (focus existing or launch + navigate), validate against path traversal, show error on invalid URLs, work cross-platform (macOS/Windows/Linux).", "timestamp": "2026-05-28T03:00:00Z"} \ No newline at end of file diff --git a/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts b/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts new file mode 100644 index 000000000..9feb7edf1 --- /dev/null +++ b/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts @@ -0,0 +1,168 @@ +import { vi } from "vitest"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; + +const { setAsDefaultProtocolClientMock, onMock, browserWindowGetAllWindowsMock } = vi.hoisted( + () => ({ + setAsDefaultProtocolClientMock: vi.fn(), + onMock: vi.fn(), + browserWindowGetAllWindowsMock: vi.fn(() => []), + }), +); + +vi.mock("electron", () => ({ + app: { + setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, + on: onMock, + }, + BrowserWindow: { + getAllWindows: browserWindowGetAllWindowsMock, + }, +})); + +import * as ElectronDeepLink from "./ElectronDeepLink.ts"; + +function runToExit(effect: Effect.Effect): Exit.Exit { + return Effect.runSyncExit(effect); +} + +describe("ElectronDeepLink", () => { + describe("parseDeepLinkUrl", () => { + it("parses settings route", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://settings"), + ); + assert.isTrue(Exit.isSuccess(exit)); + if (Exit.isSuccess(exit)) { + assert.equal(exit.value.kind, "open-settings"); + } + }); + + it("parses settings route with trailing slash", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://settings/"), + ); + assert.isTrue(Exit.isSuccess(exit)); + if (Exit.isSuccess(exit)) { + assert.equal(exit.value.kind, "open-settings"); + } + }); + + it("parses open/project route", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl( + "t3code://open/project?path=/home/user/my-project", + ), + ); + assert.isTrue(Exit.isSuccess(exit)); + if (Exit.isSuccess(exit)) { + assert.equal(exit.value.kind, "open-project"); + assert.equal(exit.value.path, "home/user/my-project"); + } + }); + + it("parses chat/thread route", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread?id=abc123"), + ); + assert.isTrue(Exit.isSuccess(exit)); + if (Exit.isSuccess(exit)) { + assert.equal(exit.value.kind, "open-thread"); + assert.equal(exit.value.id, "abc123"); + } + }); + + it("fails on unsupported protocol", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("https://example.com"), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("fails on missing id for chat/thread", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread"), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("fails on missing path for open/project", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://open/project"), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("fails on path traversal in project path", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl( + "t3code://open/project?path=../../etc/passwd", + ), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("rejects malformed URL", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("not-a-url"), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("rejects unrecognized route", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://unknown/route"), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + + it("rejects empty id in chat/thread", () => { + const exit = runToExit( + ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread?id="), + ); + assert.isTrue(Exit.isFailure(exit)); + }); + }); + + describe("validateDeepLinkPath", () => { + it("accepts normal paths with leading slash", () => { + const result = ElectronDeepLink.validateDeepLinkPath("path/to/repo"); + assert.isTrue(Option.isSome(result)); + if (Option.isSome(result)) { + assert.equal(result.value, "path/to/repo"); + } + }); + + it("normalizes redundant slashes", () => { + const result = ElectronDeepLink.validateDeepLinkPath( + "home/user//repo", + ); + assert.isTrue(Option.isSome(result)); + if (Option.isSome(result)) { + assert.equal(result.value, "home/user/repo"); + } + }); + + it("rejects path traversal", () => { + const result = ElectronDeepLink.validateDeepLinkPath("../etc/passwd"); + assert.isTrue(Option.isNone(result)); + }); + + it("rejects deep path traversal", () => { + const result = ElectronDeepLink.validateDeepLinkPath( + "a/b/../../../../etc/passwd", + ); + assert.isTrue(Option.isNone(result)); + }); + + it("handles single segment", () => { + const result = ElectronDeepLink.validateDeepLinkPath("project"); + assert.isTrue(Option.isSome(result)); + if (Option.isSome(result)) { + assert.equal(result.value, "project"); + } + }); + }); +}); diff --git a/t3code/apps/desktop/src/electron/ElectronDeepLink.ts b/t3code/apps/desktop/src/electron/ElectronDeepLink.ts new file mode 100644 index 000000000..7a77fad8c --- /dev/null +++ b/t3code/apps/desktop/src/electron/ElectronDeepLink.ts @@ -0,0 +1,194 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +import * as ElectronApp from "./ElectronApp.ts"; + +export const DEEP_LINK_SCHEME = "t3code"; + +export const DEEP_LINK_IPC_CHANNEL = "desktop:deep-link"; + +export const SUPPORTED_DEEP_LINK_PATTERNS = [ + "t3code://open/project?path=", + "t3code://chat/thread?id=", + "t3code://settings", +] as const; + +// ── Errors ──────────────────────────────────────────────────────────── + +export class DeepLinkUrlParseError extends Data.TaggedError("DeepLinkUrlParseError")<{ + readonly url: string; + readonly message: string; +}> { + override get message() { + return `Failed to parse deep link URL "${this.url}": ${this.message}`; + } +} + +export class DeepLinkPathTraversalError extends Data.TaggedError("DeepLinkPathTraversalError")<{ + readonly path: string; +}> { + override get message() { + return `Path traversal detected in deep link path: "${this.path}"`; + } +} + +// ── Route Types ─────────────────────────────────────────────────────── + +export type DeepLinkRoute = + | { readonly kind: "open-project"; readonly path: string } + | { readonly kind: "open-thread"; readonly id: string } + | { readonly kind: "open-settings" }; + +// ── Service Shape ───────────────────────────────────────────────────── + +export interface ElectronDeepLinkShape { + readonly register: Effect.Effect; +} + +export class ElectronDeepLink extends Context.Service< + ElectronDeepLink, + ElectronDeepLinkShape +>()("t3/desktop/electron/DeepLink") {} + +// ── URL Parsing ─────────────────────────────────────────────────────── + +export function parseDeepLinkUrl( + urlString: string, +): Effect.Effect { + return Effect.try({ + try: () => new URL(urlString), + catch: (cause) => + new DeepLinkUrlParseError({ + url: urlString, + message: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe(Effect.flatMap((url) => { + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + return Effect.fail( + new DeepLinkUrlParseError({ + url: urlString, + message: `Unsupported protocol: "${url.protocol}". Expected "${DEEP_LINK_SCHEME}:".`, + }), + ); + } + + const hostname = url.hostname; + const pathname = url.pathname.replace(/^\/+/, ""); + + if (hostname === "settings" || pathname === "settings") { + return Effect.succeed({ kind: "open-settings" } as DeepLinkRoute); + } + + if (hostname === "chat" || pathname.startsWith("chat/")) { + const id = url.searchParams.get("id"); + if (!id || id.trim().length === 0) { + return Effect.fail( + new DeepLinkUrlParseError({ + url: urlString, + message: 'Missing or empty "id" parameter for chat/thread route.', + }), + ); + } + return Effect.succeed({ kind: "open-thread", id: id.trim() } as DeepLinkRoute); + } + + if (hostname === "open" && pathname.startsWith("project")) { + const rawPath = url.searchParams.get("path"); + if (!rawPath || rawPath.trim().length === 0) { + return Effect.fail( + new DeepLinkUrlParseError({ + url: urlString, + message: 'Missing or empty "path" parameter for open/project route.', + }), + ); + } + const validated = validateDeepLinkPath(rawPath.trim()); + if (Option.isNone(validated)) { + return Effect.fail(new DeepLinkPathTraversalError({ path: rawPath.trim() })); + } + return Effect.succeed({ kind: "open-project", path: validated.value } as DeepLinkRoute); + } + + return Effect.fail( + new DeepLinkUrlParseError({ + url: urlString, + message: `Unrecognized deep link route: "${hostname}/${pathname}". Supported patterns: ${SUPPORTED_DEEP_LINK_PATTERNS.join(", ")}`, + }), + ); + })); +} + +// ── Path Validation ─────────────────────────────────────────────────── + +export function validateDeepLinkPath(rawPath: string): Option.Option { + // Normalize and prevent path traversal + const segments: string[] = []; + for (const segment of rawPath.split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return Option.none(); + } + segments.push(segment); + } + const normalized = segments.join("/"); + return Option.some(normalized); +} + +// ── Implementation ──────────────────────────────────────────────────── + +const make = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + + const register = Effect.gen(function* () { + // Register as default protocol client so OS routes t3code:// URLs to this app + yield* Effect.sync(() => { + Electron.app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME); + }); + + // macOS: open-url event fires when the OS opens a t3code:// URL + yield* electronApp.on("open-url", (_event: Electron.Event, url: string) => { + const result = Effect.runSync(Effect.either(parseDeepLinkUrl(url))); + if (result._tag === "Right") { + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(DEEP_LINK_IPC_CHANNEL, result.right); + } + } + } + }); + + // Windows/Linux: second-instance fires when a second instance passes a URL + yield* electronApp.on( + "second-instance", + (_event: Electron.Event, argv: readonly string[]) => { + // Find the first t3code:// URL in the argv + const deepLinkArg = argv.find((arg) => arg.startsWith(`${DEEP_LINK_SCHEME}:`)); + if (deepLinkArg) { + const result = Effect.runSync(Effect.either(parseDeepLinkUrl(deepLinkArg))); + if (result._tag === "Right") { + // Focus the existing window + const windows = Electron.BrowserWindow.getAllWindows(); + const mainWindow = windows.find((w) => !w.isDestroyed()); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + mainWindow.webContents.send(DEEP_LINK_IPC_CHANNEL, result.right); + } + } + } + }, + ); + }).pipe(Effect.withSpan("desktop.electron.deepLink.register")); + + return ElectronDeepLink.of({ register }); +}); + +export const layer = Layer.effect(ElectronDeepLink, make); diff --git a/t3code/apps/desktop/src/ipc/channels.ts b/t3code/apps/desktop/src/ipc/channels.ts index 2715b20cb..0f362cfe2 100644 --- a/t3code/apps/desktop/src/ipc/channels.ts +++ b/t3code/apps/desktop/src/ipc/channels.ts @@ -33,3 +33,4 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const DEEP_LINK_CHANNEL = "desktop:deep-link"; diff --git a/t3code/apps/desktop/src/main.ts b/t3code/apps/desktop/src/main.ts index 0bc1badff..c20743335 100644 --- a/t3code/apps/desktop/src/main.ts +++ b/t3code/apps/desktop/src/main.ts @@ -18,6 +18,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronDeepLink from "./electron/ElectronDeepLink.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -97,6 +98,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, + ElectronDeepLink.layer, ElectronProtocol.layer, DesktopSecretStorage.layer, ElectronShell.layer, diff --git a/t3code/apps/desktop/src/preload.ts b/t3code/apps/desktop/src/preload.ts index 173be8fb5..d80516a2f 100644 --- a/t3code/apps/desktop/src/preload.ts +++ b/t3code/apps/desktop/src/preload.ts @@ -95,6 +95,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { items, ...(position === undefined ? {} : { position }), }), + onDeepLink: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, route: unknown) => { + if (typeof route !== "object" || route === null) return; + listener(route as { kind: string; path?: string; id?: string }); + }; + + ipcRenderer.on(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener); + }; + }, openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/t3code/packages/contracts/src/ipc.ts b/t3code/packages/contracts/src/ipc.ts index c0515aa9b..17a896e89 100644 --- a/t3code/packages/contracts/src/ipc.ts +++ b/t3code/packages/contracts/src/ipc.ts @@ -414,6 +414,7 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + onDeepLink: (listener: (route: { kind: string; path?: string; id?: string }) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise;