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

import {
createDesktopOpenUrlBuffer,
filterDesktopDeepLinkArguments,
findDesktopDeepLink,
parseDesktopDeepLink,
} from "./DesktopDeepLink.ts";

describe("DesktopDeepLink", () => {
it("parses a production thread deep link", () => {
assert.deepEqual(parseDesktopDeepLink("t3code://threads/environment-1/thread-1", "t3code"), {
type: "thread",
environmentId: "environment-1",
threadId: "thread-1",
});
});

it("decodes environment and thread route components", () => {
assert.deepEqual(
parseDesktopDeepLink("t3code://threads/local%20machine/thread%2Fone", "t3code"),
{
type: "thread",
environmentId: "local machine",
threadId: "thread/one",
},
);
});

it("keeps production and development schemes separate", () => {
assert.isNull(parseDesktopDeepLink("t3code://threads/environment-1/thread-1", "t3code-dev"));
assert.deepEqual(
parseDesktopDeepLink("t3code-dev://threads/environment-1/thread-1", "t3code-dev"),
{
type: "thread",
environmentId: "environment-1",
threadId: "thread-1",
},
);
});

it.each([
"not-a-url",
" t3code://threads/environment-1/thread-1",
"https://threads/environment-1/thread-1",
"t3code://projects/environment-1/thread-1",
"t3code://threads/environment-only",
"t3code://threads/environment-1/thread-1/extra",
"t3code://threads/environment-1/thread-1?view=review",
"t3code://threads/environment-1/thread-1#review",
"t3code://threads//thread-1",
"t3code://threads/environment-1/",
"t3code://threads/%20/thread-1",
"t3code://threads/environment-1/%E0%A4%A",
])("rejects unsupported or malformed input: %s", (value) => {
assert.isNull(parseDesktopDeepLink(value, "t3code"));
});

it("finds a deep link among desktop process arguments", () => {
assert.deepEqual(
findDesktopDeepLink(
["/Applications/T3 Code.app/Contents/MacOS/T3 Code", "--flag", "t3code://threads/env/t1"],
"t3code",
),
{
type: "thread",
environmentId: "env",
threadId: "t1",
},
);
});

it("buffers the latest macOS URL until the lifecycle subscribes", () => {
const buffer = createDesktopOpenUrlBuffer();
const received: string[] = [];
let prevented = 0;
const event = { preventDefault: () => (prevented += 1) };

buffer.handle(event, "t3code://threads/environment-1/thread-1");
buffer.handle(event, "t3code://threads/environment-2/thread-2");
const unsubscribe = buffer.subscribe((url) => received.push(url));
buffer.handle(event, "t3code://threads/environment-3/thread-3");
unsubscribe();

assert.equal(prevented, 3);
assert.deepEqual(received, [
"t3code://threads/environment-2/thread-2",
"t3code://threads/environment-3/thread-3",
]);
});

it("removes only valid deep links from relaunch arguments", () => {
assert.deepEqual(
filterDesktopDeepLinkArguments(
[
"--flag",
"t3code://threads/environment-1/thread-1",
"t3code-dev://threads/environment-2/thread-2",
"t3code://threads/malformed",
],
"t3code",
),
["--flag", "t3code-dev://threads/environment-2/thread-2", "t3code://threads/malformed"],
);
});
});
102 changes: 102 additions & 0 deletions apps/desktop/src/app/DesktopDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { DesktopDeepLinkTarget, EnvironmentId, ThreadId } from "@t3tools/contracts";

type DesktopOpenUrlEvent = {
readonly preventDefault: () => void;
};

export function createDesktopOpenUrlBuffer() {
let pendingUrl: string | null = null;
let listener: ((url: string) => void) | null = null;

return {
handle(event: DesktopOpenUrlEvent, url: string) {
event.preventDefault();
if (listener === null) {
pendingUrl = url;
return;
}
listener(url);
},
subscribe(next: (url: string) => void) {
listener = next;
if (pendingUrl !== null) {
const url = pendingUrl;
pendingUrl = null;
next(url);
}
return () => {
if (listener === next) listener = null;
};
},
};
}

export const desktopOpenUrlBuffer = createDesktopOpenUrlBuffer();

export function parseDesktopDeepLink(
value: string,
expectedScheme: string,
): DesktopDeepLinkTarget | null {
if (value.trim() !== value) return null;

let url: URL;
try {
url = new URL(value);
} catch {
return null;
}

if (
url.protocol !== `${expectedScheme}:` ||
url.hostname !== "threads" ||
url.username.length > 0 ||
url.password.length > 0 ||
url.port.length > 0 ||
url.search.length > 0 ||
url.hash.length > 0
) {
return null;
}

const parts = url.pathname.split("/");
if (parts.length !== 3 || parts[0] !== "") return null;

try {
const environmentId = decodeURIComponent(parts[1] ?? "");
const threadId = decodeURIComponent(parts[2] ?? "");
if (
environmentId.length === 0 ||
threadId.length === 0 ||
environmentId.trim() !== environmentId ||
threadId.trim() !== threadId
) {
return null;
}

return {
type: "thread",
environmentId: EnvironmentId.make(environmentId),
threadId: ThreadId.make(threadId),
};
} catch {
return null;
}
}

export function findDesktopDeepLink(
values: ReadonlyArray<string>,
expectedScheme: string,
): DesktopDeepLinkTarget | null {
for (const value of values) {
const target = parseDesktopDeepLink(value, expectedScheme);
if (target !== null) return target;
}
return null;
}

export function filterDesktopDeepLinkArguments(
values: ReadonlyArray<string>,
expectedScheme: string,
): string[] {
return values.filter((value) => parseDesktopDeepLink(value, expectedScheme) === null);
}
125 changes: 125 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";

import type * as Electron from "electron";
import { HostProcessArguments } from "@t3tools/shared/hostProcess";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import { desktopOpenUrlBuffer } from "./DesktopDeepLink.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
Expand Down Expand Up @@ -76,6 +78,7 @@ describe("DesktopLifecycle", () => {
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchDeepLink: () => Effect.void,
dispatchMenuAction: () => Effect.void,
syncAppearance: Effect.void,
});
Expand All @@ -92,6 +95,7 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(Layer.succeed(HostProcessArguments, [])),
);

return Effect.scoped(
Expand Down Expand Up @@ -120,4 +124,125 @@ describe("DesktopLifecycle", () => {
).pipe(Effect.provide(layer));
});
}

it.effect("dispatches initial, macOS, and second-instance thread deep links", () => {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const targets: unknown[] = [];
const electronAppLayer = 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,
getAppMetrics: Effect.succeed([]),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: (eventName, listener) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set(
eventName,
listener as unknown as (...args: readonly unknown[]) => void,
);
}),
() =>
Effect.sync(() => {
appListeners.delete(eventName);
}),
).pipe(Effect.asVoid),
} satisfies ElectronApp.ElectronApp["Service"]);
const desktopWindowLayer = 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,
dispatchDeepLink: (target) =>
Effect.sync(() => {
targets.push(target);
}),
dispatchMenuAction: () => Effect.void,
syncAppearance: Effect.void,
});
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(
Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
}),
),
Layer.provideMerge(desktopWindowLayer),
Layer.provideMerge(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]),
),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(HostProcessArguments, [
"/Applications/T3 Code.app/Contents/MacOS/T3 Code",
"t3code://threads/environment-initial/thread-initial",
]),
),
);

return Effect.scoped(
Effect.gen(function* () {
let prevented = false;
desktopOpenUrlBuffer.handle(
{
preventDefault: () => {
prevented = true;
},
},
"t3code://threads/environment-open-url/thread-open-url",
);

const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

appListeners.get("second-instance")?.({} as Electron.Event, [
"T3 Code",
"t3code://threads/environment-second/thread-second",
]);
yield* Effect.promise(() => Promise.resolve());

assert.isTrue(prevented);
assert.deepEqual(targets, [
{
type: "thread",
environmentId: "environment-initial",
threadId: "thread-initial",
},
{
type: "thread",
environmentId: "environment-open-url",
threadId: "thread-open-url",
},
{
type: "thread",
environmentId: "environment-second",
threadId: "thread-second",
},
]);
}),
).pipe(Effect.provide(layer));
});
});
Loading
Loading