Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions t3code/apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions t3code/apps/desktop/src/electron/.contributor.json
Original file line number Diff line number Diff line change
@@ -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"}
168 changes: 168 additions & 0 deletions t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -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<A, E>(effect: Effect.Effect<A, E>): Exit.Exit<A, E> {
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");
}
});
});
});
194 changes: 194 additions & 0 deletions t3code/apps/desktop/src/electron/ElectronDeepLink.ts
Original file line number Diff line number Diff line change
@@ -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=<path>",
"t3code://chat/thread?id=<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<void, never, Scope.Scope>;
}

export class ElectronDeepLink extends Context.Service<
ElectronDeepLink,
ElectronDeepLinkShape
>()("t3/desktop/electron/DeepLink") {}

// ── URL Parsing ───────────────────────────────────────────────────────

export function parseDeepLinkUrl(
urlString: string,
): Effect.Effect<DeepLinkRoute, DeepLinkUrlParseError> {
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<string> {
// 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);
1 change: 1 addition & 0 deletions t3code/apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading