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
41 changes: 41 additions & 0 deletions apps/desktop/src/app/DesktopDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { assert, describe, it } from "@effect/vitest";

import { buildDesktopThreadLink, parseDesktopThreadLink } from "./DesktopDeepLink.ts";

describe("DesktopDeepLink", () => {
it("builds the desktop hash route for an externally supplied thread", () => {
assert.equal(
buildDesktopThreadLink({
isDevelopment: false,
environmentId: "environment-123",
threadId: "thread-456",
}),
"t3code://app/#/environment-123/thread-456",
);
});

it("parses only a scoped thread route from the desktop scheme", () => {
assert.deepEqual(
parseDesktopThreadLink({
isDevelopment: false,
value: "t3code://app/#/environment-123/thread-456",
}),
{
environmentId: "environment-123",
threadId: "thread-456",
},
);
assert.isNull(
parseDesktopThreadLink({
isDevelopment: false,
value: "t3code://app/CLERK-ROUTER/VIRTUAL/sign-in",
}),
);
assert.isNull(
parseDesktopThreadLink({
isDevelopment: false,
value: "t3code://other/#/environment-123/thread-456",
}),
);
});
});
56 changes: 56 additions & 0 deletions apps/desktop/src/app/DesktopDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DESKTOP_HOST, getDesktopScheme } from "../electron/ElectronProtocol.ts";

export interface DesktopThreadLink {
readonly environmentId: string;
readonly threadId: string;
}

export function buildDesktopThreadLink(input: {
readonly isDevelopment: boolean;
readonly environmentId: string;
readonly threadId: string;
}): string {
const scheme = getDesktopScheme(input.isDevelopment);
return `${scheme}://${DESKTOP_HOST}/#/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
}

function decodeThreadLinkSegment(value: string): string | null {
try {
const decoded = decodeURIComponent(value);
return decoded.trim() === decoded && decoded.length > 0 && !decoded.includes("/")
? decoded
: null;
} catch {
return null;
}
}

export function parseDesktopThreadLink(input: {
readonly isDevelopment: boolean;
readonly value: string;
}): DesktopThreadLink | null {
let url: URL;
try {
url = new URL(input.value);
} catch {
return null;
}

if (
url.protocol !== `${getDesktopScheme(input.isDevelopment)}:` ||
url.host !== DESKTOP_HOST ||
url.pathname !== "/" ||
url.search.length > 0
) {
return null;
}

const parts = url.hash.slice(1).split("/");
if (parts.length !== 3 || parts[0] !== "") {
return null;
}

const environmentId = decodeThreadLinkSegment(parts[1] ?? "");
const threadId = decodeThreadLinkSegment(parts[2] ?? "");
return environmentId === null || threadId === null ? null : { environmentId, threadId };
}
171 changes: 171 additions & 0 deletions apps/desktop/src/app/DesktopDeepLinkRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts";
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";

function makeRouterLayer(input: {
readonly listeners: Map<string, (...args: Array<unknown>) => void>;
readonly openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }>;
readonly earlyOpenUrls?: DesktopPreReadyPlatform.EarlyOpenUrlBuffer;
}) {
const electronApp = {
on: (eventName: string, listener: (...args: Array<unknown>) => void) =>
Effect.sync(() => {
input.listeners.set(eventName, listener);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const desktopWindow = {
openThread: (thread: { readonly environmentId: string; readonly threadId: string }) =>
Effect.sync(() => {
input.openedThreads.push(thread);
}),
} as unknown as DesktopWindow.DesktopWindow["Service"];
const environment = {
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"];

return DesktopDeepLinkRouter.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(ElectronApp.ElectronApp, electronApp),
Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
Layer.succeed(DesktopWindow.DesktopWindow, desktopWindow),
Layer.succeed(
DesktopPreReadyPlatform.DesktopPreReadyOpenUrls,
input.earlyOpenUrls ?? {
setHandler: () => {},
},
),
),
),
);
}

describe("DesktopDeepLinkRouter", () => {
it.effect("opens a thread link passed when the desktop app starts", () =>
Effect.gen(function* () {
const listeners = new Map<string, (...args: Array<unknown>) => void>();
const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> =
[];
const layer = makeRouterLayer({ listeners, openedThreads });
const originalArgv = process.argv;
process.argv = ["T3 Code", "t3code://app/#/environment-123/thread-456"];

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.argv = originalArgv;
}),
);

yield* Effect.scoped(
Effect.gen(function* () {
const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter;
yield* router.configure;
yield* Effect.promise(() => Promise.resolve());
assert.deepEqual(openedThreads, [
{ environmentId: "environment-123", threadId: "thread-456" },
]);
}),
).pipe(Effect.provide(layer));
}),
);

it.effect("forwards a second-instance thread link to the desktop window", () =>
Effect.gen(function* () {
const listeners = new Map<string, (...args: Array<unknown>) => void>();
const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> =
[];
const layer = makeRouterLayer({ listeners, openedThreads });

yield* Effect.scoped(
Effect.gen(function* () {
const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter;
yield* router.configure;
const secondInstance = listeners.get("second-instance");
if (!secondInstance) {
return yield* Effect.die("second-instance listener was not registered");
}

secondInstance({}, ["T3 Code", "t3code://app/#/environment-123/thread-456"]);
yield* Effect.promise(() => Promise.resolve());
assert.deepEqual(openedThreads, [
{ environmentId: "environment-123", threadId: "thread-456" },
]);
}),
).pipe(Effect.provide(layer));
}),
);

it.effect("forwards a macOS URL activation to the desktop window", () =>
Effect.gen(function* () {
let openUrlListener: ((event: unknown, url: string) => void) | undefined;
const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({
platform: "darwin",
electronApp: {
on: (_eventName, listener) => {
openUrlListener = listener;
},
},
});
const listeners = new Map<string, (...args: Array<unknown>) => void>();
const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> =
[];
const layer = makeRouterLayer({ listeners, openedThreads, earlyOpenUrls });

yield* Effect.scoped(
Effect.gen(function* () {
const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter;
yield* router.configure;
if (!openUrlListener) {
return yield* Effect.die("open-url listener was not registered");
}

openUrlListener({}, "t3code://app/#/environment-123/thread-456");
yield* Effect.promise(() => Promise.resolve());
assert.deepEqual(openedThreads, [
{ environmentId: "environment-123", threadId: "thread-456" },
]);
}),
).pipe(Effect.provide(layer));
}),
);

it.effect("routes a macOS URL captured before lifecycle registration", () =>
Effect.gen(function* () {
let openUrlListener: ((event: unknown, url: string) => void) | undefined;
const earlyOpenUrls = DesktopPreReadyPlatform.makeEarlyOpenUrlBuffer({
platform: "darwin",
electronApp: {
on: (_eventName, listener) => {
openUrlListener = listener;
},
},
});
const listeners = new Map<string, (...args: Array<unknown>) => void>();
const openedThreads: Array<{ readonly environmentId: string; readonly threadId: string }> =
[];
const layer = makeRouterLayer({ listeners, openedThreads, earlyOpenUrls });

if (!openUrlListener) {
return yield* Effect.die("open-url listener was not registered");
}
openUrlListener({}, "t3code://app/#/environment-123/thread-456");

yield* Effect.scoped(
Effect.gen(function* () {
const router = yield* DesktopDeepLinkRouter.DesktopDeepLinkRouter;
yield* router.configure;
yield* Effect.promise(() => Promise.resolve());
assert.deepEqual(openedThreads, [
{ environmentId: "environment-123", threadId: "thread-456" },
]);
}),
).pipe(Effect.provide(layer));
}),
);
});
60 changes: 60 additions & 0 deletions apps/desktop/src/app/DesktopDeepLinkRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Scope from "effect/Scope";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import { makeComponentLogger } from "./DesktopObservability.ts";
import { parseDesktopThreadLink } from "./DesktopDeepLink.ts";
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";

const { logWarning } = makeComponentLogger("desktop-deep-link");

export class DesktopDeepLinkRouter extends Context.Service<
DesktopDeepLinkRouter,
{
readonly configure: Effect.Effect<void, never, Scope.Scope>;
}
>()("@t3tools/desktop/app/DesktopDeepLinkRouter") {}

export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const electronApp = yield* ElectronApp.ElectronApp;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const preReadyOpenUrls = yield* DesktopPreReadyPlatform.DesktopPreReadyOpenUrls;
const context = yield* Effect.context<DesktopWindow.DesktopWindow>();
const runPromise = Effect.runPromiseWith(context);

const openFirstThreadLink = (values: ReadonlyArray<string>) => {
const thread = values
.map((value) => parseDesktopThreadLink({ isDevelopment: environment.isDevelopment, value }))
.find((value) => value !== null);
if (thread === undefined || thread === null) return;

void runPromise(
desktopWindow.openThread(thread).pipe(
Effect.catch((error) =>
logWarning("failed to open thread deep link", {
message: error.message,
}),
),
),
);
};

return DesktopDeepLinkRouter.of({
configure: Effect.gen(function* () {
openFirstThreadLink(process.argv);
preReadyOpenUrls.setHandler((url) => {
openFirstThreadLink([url]);
});
yield* electronApp.on("second-instance", (_event, commandLine: string[]) => {
openFirstThreadLink(commandLine);
});
}).pipe(Effect.withSpan("desktop.deepLink.configure")),
});
});

export const layer = Layer.effect(DesktopDeepLinkRouter, make);
11 changes: 11 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type * as Electron from "electron";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopDeepLinkRouter from "./DesktopDeepLinkRouter.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
Expand All @@ -17,6 +18,7 @@ describe("DesktopLifecycle", () => {
for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
let deepLinkRouterConfigurations = 0;

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
Expand Down Expand Up @@ -71,6 +73,7 @@ describe("DesktopLifecycle", () => {
createMain: Effect.die("unexpected window creation"),
ensureMain: Effect.die("unexpected window creation"),
revealOrCreateMain: Effect.die("unexpected window creation"),
openThread: () => Effect.void,
activate: Effect.void,
createMainIfBackendReady: Effect.void,
showConnectingSplash: Effect.void,
Expand All @@ -91,6 +94,13 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(desktopWindowLayer),
Layer.provideMerge(
Layer.succeed(DesktopDeepLinkRouter.DesktopDeepLinkRouter, {
configure: Effect.sync(() => {
deepLinkRouterConfigurations += 1;
}),
}),
),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Expand All @@ -100,6 +110,7 @@ describe("DesktopLifecycle", () => {
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;
assert.equal(deepLinkRouterConfigurations, 1);

appListeners.get("before-quit-for-update")?.();

Expand Down
Loading
Loading