Repository navigation
feat(desktop): open threads from external links #7383
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
59f4eca
feat(desktop): open threads from external links
mrmg f328ae1
fix(desktop): coalesce deep-link window creation
mrmg 95861f6
fix(desktop): buffer macOS deep links
mrmg 0a6ae84
fix(desktop): sequence early link setup
mrmg 45c4b25
fix(desktop): preserve deep links during recovery
mrmg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| }), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.