Skip to content

Commit 6bda22a

Browse files
omegent-app[bot]patroza
authored andcommitted
feat(desktop): open threads via t3code:// deep links
Allow external launchers to open a connection-scoped thread with t3code://open/thread?connection=<label>&thread=<id>, resolving the label through the live connection catalog for cold start and second-instance delivery.
1 parent 6a6d2a4 commit 6bda22a

12 files changed

Lines changed: 1068 additions & 17 deletions

apps/desktop/src/app/DesktopApp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1212
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1313
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1414
import * as DesktopClerk from "./DesktopClerk.ts";
15+
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
1516
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
1617
import * as DesktopWindow from "../window/DesktopWindow.ts";
1718
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
@@ -232,6 +233,12 @@ const bootstrap = Effect.gen(function* () {
232233
// slow first wsl.exe spawn.
233234
yield* Effect.forkScoped(wslBackend.reconcile);
234235
}
236+
237+
// Catalog + window services are usable; flush any deep link captured from
238+
// initial argv / open-url during single-instance setup.
239+
const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks;
240+
yield* deepLinks.start;
241+
yield* logBootstrapInfo("bootstrap deep links ready");
235242
}).pipe(Effect.withSpan("desktop.bootstrap"));
236243

237244
const startup = Effect.gen(function* () {

apps/desktop/src/app/DesktopClerk.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest";
22
import * as Cause from "effect/Cause";
33
import * as Effect from "effect/Effect";
44
import * as Layer from "effect/Layer";
5+
import * as Ref from "effect/Ref";
56
import { beforeEach, vi } from "vite-plus/test";
67

78
const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({
@@ -22,13 +23,16 @@ vi.mock("@clerk/electron/storage", () => ({
2223
storage: storageMock,
2324
}));
2425

26+
import * as ElectronApp from "../electron/ElectronApp.ts";
2527
import * as DesktopClerk from "./DesktopClerk.ts";
28+
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
2629
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2730

28-
const makeDesktopClerkLayer = (isDevelopment = true) => {
31+
const makeDesktopClerkLayer = (isDevelopment = true, isPackaged = false) => {
2932
const environment = DesktopEnvironment.DesktopEnvironment.of({
3033
stateDir: "/tmp/t3-state",
3134
isDevelopment,
35+
isPackaged,
3236
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
3337

3438
return DesktopClerk.layer.pipe(
@@ -146,4 +150,150 @@ describe("DesktopClerk", () => {
146150
storageMock.mockClear();
147151
createClerkBridgeMock.mockClear();
148152
});
153+
154+
it.effect(
155+
"wires second-instance argv into deep links and registers the protocol when packaged",
156+
() => {
157+
storageMock.mockReturnValue(storageAdapter);
158+
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() });
159+
160+
return Effect.gen(function* () {
161+
const handledArgv = yield* Ref.make<Array<readonly string[]>>([]);
162+
const handledUrls = yield* Ref.make<string[]>([]);
163+
const listeners = new Map<string, (...args: readonly unknown[]) => void>();
164+
let protocolClientRegistered = false;
165+
166+
const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, {
167+
handleArgv: (argv) =>
168+
Ref.update(handledArgv, (items) => [...items, argv]).pipe(Effect.asVoid),
169+
handleUrl: (url) =>
170+
Ref.update(handledUrls, (items) => [...items, url]).pipe(Effect.asVoid),
171+
start: Effect.void,
172+
} satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]);
173+
174+
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
175+
metadata: Effect.die("unexpected metadata"),
176+
name: Effect.succeed("T3 Code"),
177+
whenReady: Effect.void,
178+
quit: Effect.void,
179+
exit: () => Effect.void,
180+
relaunch: () => Effect.void,
181+
setPath: () => Effect.void,
182+
setName: () => Effect.void,
183+
setAboutPanelOptions: () => Effect.void,
184+
setAppUserModelId: () => Effect.void,
185+
requestSingleInstanceLock: Effect.succeed(true),
186+
isDefaultProtocolClient: () => Effect.succeed(false),
187+
setAsDefaultProtocolClient: (protocol: string) =>
188+
Effect.sync(() => {
189+
protocolClientRegistered = protocol === "t3code";
190+
return true;
191+
}),
192+
setDesktopName: () => Effect.void,
193+
setDockIcon: () => Effect.void,
194+
appendCommandLineSwitch: () => Effect.void,
195+
on: <Args extends ReadonlyArray<unknown>>(
196+
eventName: string,
197+
listener: (...args: Args) => void,
198+
) =>
199+
Effect.sync(() => {
200+
listeners.set(eventName, listener as (...args: readonly unknown[]) => void);
201+
}).pipe(Effect.asVoid),
202+
} as unknown as ElectronApp.ElectronApp["Service"]);
203+
204+
const runtimeLayer = Layer.mergeAll(
205+
makeDesktopClerkLayer(false, true),
206+
deepLinksLayer,
207+
electronAppLayer,
208+
);
209+
210+
yield* Effect.scoped(
211+
Effect.gen(function* () {
212+
const clerk = yield* DesktopClerk.DesktopClerk;
213+
yield* clerk.configure;
214+
215+
assert.isTrue(protocolClientRegistered);
216+
assert.isTrue(listeners.has("second-instance"));
217+
assert.isTrue(listeners.has("open-url"));
218+
219+
// Initial process.argv is captured during configure.
220+
const initialHandled = yield* Ref.get(handledArgv);
221+
assert.isTrue(initialHandled.length >= 1);
222+
223+
const secondInstance = listeners.get("second-instance");
224+
assert.isDefined(secondInstance);
225+
secondInstance?.({}, [
226+
"t3code",
227+
"t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463",
228+
]);
229+
// Allow the fire-and-forget runPromise callback to settle.
230+
yield* Effect.yieldNow;
231+
yield* Effect.yieldNow;
232+
233+
const afterSecond = yield* Ref.get(handledArgv);
234+
assert.isTrue(
235+
afterSecond.some((argv) =>
236+
argv.some((entry) => entry.startsWith("t3code://open/thread")),
237+
),
238+
);
239+
240+
const openUrl = listeners.get("open-url");
241+
assert.isDefined(openUrl);
242+
const preventDefault = vi.fn();
243+
openUrl?.(
244+
{ preventDefault },
245+
"t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
246+
);
247+
yield* Effect.yieldNow;
248+
yield* Effect.yieldNow;
249+
assert.equal(preventDefault.mock.calls.length, 1);
250+
const urls = yield* Ref.get(handledUrls);
251+
assert.deepEqual(urls, [
252+
"t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
253+
]);
254+
}).pipe(Effect.provide(runtimeLayer)),
255+
);
256+
});
257+
},
258+
);
259+
260+
it.effect("does not register the OS protocol client in development", () => {
261+
storageMock.mockReturnValue(storageAdapter);
262+
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() });
263+
264+
return Effect.gen(function* () {
265+
let protocolClientRegistered = false;
266+
267+
const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, {
268+
handleArgv: () => Effect.void,
269+
handleUrl: () => Effect.void,
270+
start: Effect.void,
271+
} satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]);
272+
273+
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
274+
requestSingleInstanceLock: Effect.succeed(true),
275+
setAsDefaultProtocolClient: () =>
276+
Effect.sync(() => {
277+
protocolClientRegistered = true;
278+
return true;
279+
}),
280+
on: () => Effect.void,
281+
quit: Effect.void,
282+
} as unknown as ElectronApp.ElectronApp["Service"]);
283+
284+
const runtimeLayer = Layer.mergeAll(
285+
makeDesktopClerkLayer(true, false),
286+
deepLinksLayer,
287+
electronAppLayer,
288+
);
289+
290+
yield* Effect.scoped(
291+
Effect.gen(function* () {
292+
const clerk = yield* DesktopClerk.DesktopClerk;
293+
yield* clerk.configure;
294+
assert.isFalse(protocolClientRegistered);
295+
}).pipe(Effect.provide(runtimeLayer)),
296+
);
297+
});
298+
});
149299
});

apps/desktop/src/app/DesktopClerk.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@ import { storage } from "@clerk/electron/storage";
33
import * as Context from "effect/Context";
44
import * as Effect from "effect/Effect";
55
import * as Layer from "effect/Layer";
6-
import * as Option from "effect/Option";
76
import * as Schema from "effect/Schema";
87
import * as Scope from "effect/Scope";
98

109
import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
1110
import * as ElectronApp from "../electron/ElectronApp.ts";
1211
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
13-
import * as ElectronWindow from "../electron/ElectronWindow.ts";
12+
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
1413
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1514

1615
declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
@@ -47,7 +46,7 @@ export class DesktopClerk extends Context.Service<
4746
readonly configure: Effect.Effect<
4847
void,
4948
never,
50-
ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
49+
DesktopDeepLinks.DesktopDeepLinks | ElectronApp.ElectronApp | Scope.Scope
5150
>;
5251
}
5352
>()("@t3tools/desktop/app/DesktopClerk") {}
@@ -109,25 +108,37 @@ export const make = Effect.gen(function* () {
109108
return DesktopClerk.of({
110109
configure: Effect.gen(function* () {
111110
const electronApp = yield* ElectronApp.ElectronApp;
112-
const electronWindow = yield* ElectronWindow.ElectronWindow;
113-
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
111+
const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks;
112+
// Capture ambient services for Electron event callbacks, which cannot yield.
113+
const context = yield* Effect.context<never>();
114114
const runPromise = Effect.runPromiseWith(context);
115115

116116
if (!(yield* electronApp.requestSingleInstanceLock)) {
117117
yield* electronApp.quit;
118118
return yield* Effect.interrupt;
119119
}
120120

121-
yield* electronApp.on("second-instance", () => {
122-
void runPromise(
123-
Effect.gen(function* () {
124-
const mainWindow = yield* electronWindow.currentMainOrFirst;
125-
if (Option.isSome(mainWindow)) {
126-
yield* electronWindow.reveal(mainWindow.value);
127-
}
128-
}),
129-
);
121+
// Register before readiness so cold-start and second-instance deep links
122+
// are not dropped. Deep-link processing itself queues until start().
123+
yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[] = []) => {
124+
void runPromise(deepLinks.handleArgv(argv));
125+
});
126+
127+
// macOS delivers custom URL scheme activations through open-url.
128+
yield* electronApp.on("open-url", (event: { preventDefault?: () => void }, url: string) => {
129+
event.preventDefault?.();
130+
void runPromise(deepLinks.handleUrl(url));
130131
});
132+
133+
// Packaged builds own the OS protocol handler. Skip in development so a
134+
// local electron binary does not replace the installed t3code handler.
135+
if (environment.isPackaged && !environment.isDevelopment) {
136+
yield* electronApp.setAsDefaultProtocolClient(DesktopDeepLinks.DESKTOP_EXTERNAL_PROTOCOL);
137+
}
138+
139+
// Initial argv may already contain a deep link (direct CLI invocation or
140+
// protocol launch on Linux/Windows).
141+
yield* deepLinks.handleArgv(process.argv);
131142
}).pipe(Effect.withSpan("desktop.clerk.configure")),
132143
});
133144
});

0 commit comments

Comments
 (0)