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
5 changes: 5 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts";
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
Expand Down Expand Up @@ -224,6 +225,7 @@ const startup = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
const deepLinkRouter = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter;
const clerk = yield* DesktopClerk.DesktopClerk;
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
Expand Down Expand Up @@ -271,6 +273,9 @@ const startup = Effect.gen(function* () {
yield* appIdentity.configure;
yield* lifecycle.register;
yield* clerk.configure;
// macOS can emit open-url before ready, and does not preserve that URL in
// argv. Register now; DesktopWindow holds it until the backend can open.
yield* deepLinkRouter.register;

yield* electronApp.whenReady.pipe(
Effect.withSpan("desktop.electron.whenReady"),
Expand Down
110 changes: 110 additions & 0 deletions apps/desktop/src/app/DesktopDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vite-plus/test";

import { findDeepLinkInArgv, parseDeepLink } from "./DesktopDeepLink.ts";

const SCHEMES = ["t3code", "t3code-dev"] as const;

describe("parseDeepLink", () => {
it("parses the documented thread link shape", () => {
expect(parseDeepLink("t3code://threads/env-1/thread-42", SCHEMES)).toEqual({
kind: "thread",
environmentId: "env-1",
threadId: "thread-42",
});
});

it("accepts every registered scheme, including the development one", () => {
for (const scheme of SCHEMES) {
expect(parseDeepLink(`${scheme}://threads/env/thread`, SCHEMES)).toEqual({
kind: "thread",
environmentId: "env",
threadId: "thread",
});
}
});

it("is case insensitive for the scheme and host", () => {
expect(parseDeepLink("T3CODE://THREADS/env/thread", SCHEMES)).toEqual({
kind: "thread",
environmentId: "env",
threadId: "thread",
});
});

it("percent-decodes segments", () => {
expect(parseDeepLink("t3code://threads/env%20one/thread%2Btwo", SCHEMES)).toEqual({
kind: "thread",
environmentId: "env one",
threadId: "thread+two",
});
});

it("rejects foreign schemes so other protocol handlers are never hijacked", () => {
expect(parseDeepLink("https://threads/env/thread", SCHEMES)).toBeNull();
expect(parseDeepLink("t3codex://threads/env/thread", SCHEMES)).toBeNull();
});

it("rejects other hosts, including the renderer bundle URL", () => {
expect(parseDeepLink("t3code://app/#/env/thread", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://oauth/callback", SCHEMES)).toBeNull();
});

it("requires exactly two path segments", () => {
expect(parseDeepLink("t3code://threads/env", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://threads/env/thread/extra", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://threads", SCHEMES)).toBeNull();
});

// A decoded separator would let a crafted link address a different route than
// the one the two segments appear to describe.
it("rejects segments that decode back into path separators", () => {
expect(parseDeepLink("t3code://threads/env%2F..%2Fother/thread", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://threads/env/thread%3Fx%3D1", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://threads/env/thread%23frag", SCHEMES)).toBeNull();
});

it("rejects blank or whitespace-padded segments", () => {
expect(parseDeepLink("t3code://threads/%20/thread", SCHEMES)).toBeNull();
expect(parseDeepLink("t3code://threads/env/%20thread", SCHEMES)).toBeNull();
});

it("rejects malformed percent-encoding instead of throwing", () => {
expect(parseDeepLink("t3code://threads/%E0%A4%A/thread", SCHEMES)).toBeNull();
});

it("rejects input that is not a URL at all", () => {
expect(parseDeepLink("", SCHEMES)).toBeNull();
expect(parseDeepLink("not a url", SCHEMES)).toBeNull();
});
});

describe("findDeepLinkInArgv", () => {
// Electron passes the full argv of the second launch, so the URL is never the
// first element in practice.
it("finds the link among unrelated arguments", () => {
expect(
findDeepLinkInArgv(
["/path/to/T3 Code", "--allow-file-access", "t3code://threads/env/thread"],
SCHEMES,
),
).toBe("t3code://threads/env/thread");
});

it("trims surrounding whitespace added by the shell or desktop entry", () => {
expect(findDeepLinkInArgv([" t3code://threads/env/thread "], SCHEMES)).toBe(
"t3code://threads/env/thread",
);
});

it("returns the first match when several links are present", () => {
expect(findDeepLinkInArgv(["t3code://threads/a/b", "t3code://threads/c/d"], SCHEMES)).toBe(
"t3code://threads/a/b",
);
});

it("returns null when no argument uses a registered scheme", () => {
expect(findDeepLinkInArgv(["/path/to/T3 Code", "--flag"], SCHEMES)).toBeNull();
expect(findDeepLinkInArgv(["https://example.com"], SCHEMES)).toBeNull();
expect(findDeepLinkInArgv([], SCHEMES)).toBeNull();
});
});
103 changes: 103 additions & 0 deletions apps/desktop/src/app/DesktopDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { DesktopDeepLinkTarget } from "@t3tools/contracts";

// Parsing for the `t3code://` deep links that address an existing thread.
//
// The external contract is the same one the mobile app and widgets already use:
//
// t3code://threads/<environmentId>/<threadId>
//
// which maps onto the renderer's typed route `/$environmentId/$threadId`. Only
// the external shape is stable; the renderer route stays an implementation
// detail, so callers outside the app never depend on the hash history layout.
//
// Parsing lives here as plain functions (no Effect) so the URL edge cases can be
// unit tested without an Electron or Effect runtime, mirroring wslPathParsing.

export const DEEP_LINK_THREAD_HOST = "threads";

// Re-exported so main-process modules do not each reach into the contracts
// package for what is, from their side, this module's return type.
export type DeepLinkTarget = DesktopDeepLinkTarget;

// Path segments are percent-decoded by `URL`, but a decoded segment can still
// be empty, contain a path separator, or be pure whitespace when the link was
// hand-written or truncated by a chat client. Anything that is not a single
// clean segment is rejected rather than normalized: a wrong-but-plausible id
// would navigate to someone else's thread, which is worse than not navigating.
function isCleanSegment(value: string): boolean {
if (value.length === 0 || value.trim().length !== value.length) return false;
return !/[/\\?#]/.test(value);
}

/**
* Parses a deep link URL into a navigation target.
*
* Returns `null` for anything that is not a well-formed thread link, including
* links using a different scheme, so callers can fall back to simply revealing
* the window.
*/
export function parseDeepLink(rawUrl: string, schemes: readonly string[]): DeepLinkTarget | null {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return null;
}

// `URL.protocol` keeps the trailing colon.
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
if (!schemes.some((candidate) => candidate.toLowerCase() === scheme)) {
return null;
}

// `t3code://threads/<env>/<thread>` puts `threads` in the host position, not
// in the path — the authority component absorbs the first segment.
if (url.hostname.toLowerCase() !== DEEP_LINK_THREAD_HOST) {
return null;
}

const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
if (segments.length !== 2) {
return null;
}

let environmentId: string;
let threadId: string;
try {
environmentId = decodeURIComponent(segments[0] ?? "");
threadId = decodeURIComponent(segments[1] ?? "");
} catch {
// Malformed percent-encoding.
return null;
}

if (!isCleanSegment(environmentId) || !isCleanSegment(threadId)) {
return null;
}

return { kind: "thread", environmentId, threadId };
}

/**
* Extracts the first deep link from a process argv vector.
*
* Windows and Linux deliver protocol URLs as a command-line argument — both on
* first launch and, for an already-running app, through Electron's
* `second-instance` event. macOS instead emits `open-url`, so this is not used
* there.
*/
export function findDeepLinkInArgv(
argv: readonly string[],
schemes: readonly string[],
): string | null {
for (const arg of argv) {
if (typeof arg !== "string") continue;
const trimmed = arg.trim();
if (trimmed.length === 0) continue;
if (!schemes.some((scheme) => trimmed.toLowerCase().startsWith(`${scheme.toLowerCase()}://`))) {
continue;
}
return trimmed;
}
return null;
}
42 changes: 42 additions & 0 deletions apps/desktop/src/app/DesktopDeepLinkBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { decodeDesktopDeepLinkTarget, makeDesktopDeepLinkBuffer } from "./DesktopDeepLinkBuffer.ts";

const target = {
kind: "thread" as const,
environmentId: "environment-1",
threadId: "thread-1",
};

describe("DesktopDeepLinkBuffer", () => {
it("replays the latest target received before the renderer subscribes", () => {
const buffer = makeDesktopDeepLinkBuffer();
buffer.push({ ...target, threadId: "stale" });
buffer.push(target);
const listener = vi.fn();

buffer.subscribe(listener);

expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(target);
});

it("delivers later targets directly and stops after unsubscribe", () => {
const buffer = makeDesktopDeepLinkBuffer();
const listener = vi.fn();
const unsubscribe = buffer.subscribe(listener);

buffer.push(target);
unsubscribe();
buffer.push({ ...target, threadId: "later" });

expect(listener).toHaveBeenCalledOnce();
});

it("rejects malformed renderer-bound targets", () => {
expect(decodeDesktopDeepLinkTarget(target)).toEqual(target);
expect(decodeDesktopDeepLinkTarget(null)).toBeNull();
expect(decodeDesktopDeepLinkTarget({ ...target, threadId: 1 })).toBeNull();
expect(decodeDesktopDeepLinkTarget({ ...target, kind: "project" })).toBeNull();
});
});
43 changes: 43 additions & 0 deletions apps/desktop/src/app/DesktopDeepLinkBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { DesktopDeepLinkTarget } from "@t3tools/contracts";

export function decodeDesktopDeepLinkTarget(value: unknown): DesktopDeepLinkTarget | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as Record<string, unknown>;
if (
candidate.kind !== "thread" ||
typeof candidate.environmentId !== "string" ||
typeof candidate.threadId !== "string"
) {
return null;
}
return {
kind: "thread",
environmentId: candidate.environmentId,
threadId: candidate.threadId,
};
}

export function makeDesktopDeepLinkBuffer() {
const listeners = new Set<(target: DesktopDeepLinkTarget) => void>();
let pending: DesktopDeepLinkTarget | null = null;

const push = (target: DesktopDeepLinkTarget) => {
if (listeners.size === 0) {
pending = target;
return;
}
for (const listener of listeners) listener(target);
};

const subscribe = (listener: (target: DesktopDeepLinkTarget) => void) => {
listeners.add(listener);
if (pending !== null) {
const target = pending;
pending = null;
listener(target);
}
return () => listeners.delete(listener);
};

return { push, subscribe };
}
Loading
Loading