-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(desktop): register t3:// OS protocol handler for thread deep links #2424
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import * as Context from "effect/Context"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Scope from "effect/Scope"; | ||
|
|
||
| import type * as Electron from "electron"; | ||
|
|
||
| import { makeComponentLogger } from "./DesktopObservability.ts"; | ||
| import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| import { | ||
| DESKTOP_THREAD_DEEP_LINK_SCHEME, | ||
| findThreadDeepLinkInArgv, | ||
| parseThreadDeepLinkUrl, | ||
| } from "../deeplink/threadDeepLink.ts"; | ||
| import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| import * as DesktopWindow from "../window/DesktopWindow.ts"; | ||
| import type { DesktopWindowError } from "../window/DesktopWindow.ts"; | ||
|
|
||
| type DesktopDeepLinkRuntimeServices = | ||
| | DesktopEnvironment.DesktopEnvironment | ||
| | DesktopWindow.DesktopWindow | ||
| | ElectronApp.ElectronApp; | ||
|
|
||
| /** | ||
| * @effect-expect-leaking DesktopEnvironment | DesktopWindow | ElectronApp | ||
| */ | ||
| export class DesktopDeepLinks extends Context.Service< | ||
| DesktopDeepLinks, | ||
| { | ||
| readonly registerEarly: Effect.Effect< | ||
| void, | ||
| never, | ||
| Scope.Scope | DesktopDeepLinkRuntimeServices | ||
| >; | ||
| readonly configure: Effect.Effect<void, never, DesktopDeepLinkRuntimeServices>; | ||
| } | ||
| >()("@t3tools/desktop/app/DesktopDeepLinks") {} | ||
|
|
||
| const { logInfo: logDeepLinkInfo, logError: logDeepLinkError } = | ||
| makeComponentLogger("desktop-deeplink"); | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| const electronApp = yield* ElectronApp.ElectronApp; | ||
| const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| const context = yield* Effect.context<DesktopDeepLinkRuntimeServices>(); | ||
| const runPromise = Effect.runPromiseWith(context); | ||
|
|
||
| const openThreadDeepLink = (threadId: string) => | ||
| Effect.gen(function* () { | ||
| yield* logDeepLinkInfo("open thread deep link", { threadId }); | ||
| yield* desktopWindow | ||
| .openThread(threadId) | ||
| .pipe( | ||
| Effect.catch((error: DesktopWindowError) => | ||
| logDeepLinkError("failed to open thread deep link", { threadId, error }), | ||
| ), | ||
| ); | ||
| }).pipe(Effect.withSpan("desktop.deeplink.openThread")); | ||
|
|
||
| const handleDeepLinkUrl = (rawUrl: string) => | ||
| Effect.gen(function* () { | ||
| const threadId = parseThreadDeepLinkUrl(rawUrl); | ||
| if (Option.isNone(threadId)) { | ||
| return; | ||
| } | ||
| yield* openThreadDeepLink(threadId.value); | ||
| }).pipe(Effect.withSpan("desktop.deeplink.handleUrl")); | ||
|
|
||
| return DesktopDeepLinks.of({ | ||
| registerEarly: Effect.gen(function* () { | ||
| yield* electronApp.on("open-url", (event: Electron.Event, url: string) => { | ||
| event.preventDefault(); | ||
| void runPromise(handleDeepLinkUrl(url)); | ||
| }); | ||
|
|
||
| yield* electronApp.on( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium The 🤖 Copy this AI Prompt to have your agent fix this: |
||
| "second-instance", | ||
| (_event: Electron.Event, argv: readonly string[]) => { | ||
| const launchUrl = findThreadDeepLinkInArgv(argv); | ||
| if (Option.isSome(launchUrl)) { | ||
| void runPromise(openThreadDeepLink(launchUrl.value)); | ||
| } | ||
| }, | ||
| ); | ||
| }).pipe(Effect.withSpan("desktop.deeplink.registerEarly")), | ||
|
|
||
| configure: Effect.gen(function* () { | ||
| // Pin registration to this executable on macOS so Launch Services routes | ||
| // `t3://` links to the running channel (Alpha/Nightly) instead of whichever | ||
| // app happens to share the production bundle id. | ||
| const registered = yield* environment.platform === "darwin" | ||
| ? electronApp.setAsDefaultProtocolClient( | ||
| DESKTOP_THREAD_DEEP_LINK_SCHEME, | ||
| process.execPath, | ||
| [], | ||
| ) | ||
| : electronApp.setAsDefaultProtocolClient(DESKTOP_THREAD_DEEP_LINK_SCHEME); | ||
| if (!registered) { | ||
| yield* logDeepLinkError("failed to register default protocol client", { | ||
| scheme: DESKTOP_THREAD_DEEP_LINK_SCHEME, | ||
| }); | ||
| } | ||
|
|
||
| const launchThreadId = findThreadDeepLinkInArgv(process.argv); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium On startup, 🤖 Copy this AI Prompt to have your agent fix this: |
||
| if (Option.isSome(launchThreadId)) { | ||
| yield* openThreadDeepLink(launchThreadId.value); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. macOS cold start duplicate opensMedium Severity On macOS, launching via Additional Locations (1)Reviewed by Cursor Bugbot for commit 75a2103. Configure here. |
||
| }).pipe(Effect.withSpan("desktop.deeplink.configure")), | ||
| }); | ||
| }); | ||
|
|
||
| export const layer = Layer.effect(DesktopDeepLinks, make); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import * as Option from "effect/Option"; | ||
|
|
||
| import { findThreadDeepLinkInArgv, parseThreadDeepLinkUrl } from "./threadDeepLink.ts"; | ||
|
|
||
| describe("threadDeepLink", () => { | ||
| it("parses t3://thread/<threadId> URLs", () => { | ||
| assert.deepStrictEqual( | ||
| parseThreadDeepLinkUrl("t3://thread/thread-123"), | ||
| Option.some("thread-123"), | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects unrelated protocols and hosts", () => { | ||
| assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3code://app/"))); | ||
| assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3://settings/general"))); | ||
| assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("https://example.com/thread/abc"))); | ||
| }); | ||
|
|
||
| it("finds deep links in process argv", () => { | ||
| assert.deepStrictEqual( | ||
| findThreadDeepLinkInArgv([ | ||
| "/Applications/T3 Code.app/Contents/MacOS/T3 Code", | ||
| "t3://thread/abc", | ||
| ]), | ||
| Option.some("abc"), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import * as Option from "effect/Option"; | ||
|
|
||
| /** OS-registered scheme for thread deep links (`t3://thread/<threadId>`). */ | ||
| export const DESKTOP_THREAD_DEEP_LINK_SCHEME = "t3"; | ||
|
|
||
| export function parseThreadDeepLinkUrl(rawUrl: string): Option.Option<string> { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(rawUrl); | ||
| } catch { | ||
| return Option.none(); | ||
| } | ||
|
|
||
| if (parsed.protocol !== `${DESKTOP_THREAD_DEEP_LINK_SCHEME}:` || parsed.hostname !== "thread") { | ||
| return Option.none(); | ||
| } | ||
|
|
||
| const threadId = parsed.pathname.replace(/^\/+/, ""); | ||
| return threadId.length > 0 ? Option.some(threadId) : Option.none(); | ||
| } | ||
|
|
||
| export function findThreadDeepLinkInArgv(argv: readonly string[]): Option.Option<string> { | ||
| for (const arg of argv) { | ||
| const threadId = parseThreadDeepLinkUrl(arg); | ||
| if (Option.isSome(threadId)) { | ||
| return threadId; | ||
| } | ||
| } | ||
| return Option.none(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -116,6 +116,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { | |
| ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); | ||
| }; | ||
| }, | ||
| onOpenThread: (listener) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High The Also found in 1 other location(s)
🤖 Copy this AI Prompt to have your agent fix this: |
||
| const wrappedListener = (_event: Electron.IpcRendererEvent, threadId: unknown) => { | ||
| if (typeof threadId !== "string") return; | ||
| listener(threadId); | ||
| }; | ||
|
|
||
| ipcRenderer.on(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener); | ||
| return () => { | ||
| ipcRenderer.removeListener(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener); | ||
| }; | ||
| }, | ||
| getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), | ||
| setUpdateChannel: (channel) => | ||
| ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
|
|
||
| import { resolveDesktopAppBundleId, resolveDesktopBuildAppId } from "./updateChannels.ts"; | ||
|
|
||
| describe("updateChannels bundle ids", () => { | ||
| it("uses dev, nightly, and alpha bundle ids per channel", () => { | ||
| assert.equal( | ||
| resolveDesktopAppBundleId({ isDevelopment: true, appVersion: "0.0.28" }), | ||
| "com.t3tools.t3code.dev", | ||
| ); | ||
| assert.equal( | ||
| resolveDesktopAppBundleId({ | ||
| isDevelopment: false, | ||
| appVersion: "0.0.28-nightly.20260702.1", | ||
| }), | ||
| "com.t3tools.t3code.nightly", | ||
| ); | ||
| assert.equal( | ||
| resolveDesktopAppBundleId({ isDevelopment: false, appVersion: "0.0.28" }), | ||
| "com.t3tools.t3code.alpha", | ||
| ); | ||
| assert.equal( | ||
| resolveDesktopBuildAppId("0.0.28-nightly.20260702.1"), | ||
| "com.t3tools.t3code.nightly", | ||
| ); | ||
| assert.equal(resolveDesktopBuildAppId("0.0.28"), "com.t3tools.t3code.alpha"); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
app/DesktopApp.ts:243deepLinks.registerEarlyis called aftershellEnvironment.installIntoProcess,resolveUserDataPath,electronApp.setPath,desktopSettings.load,appIdentity.configure, andlifecycle.register. On a macOS cold start, Electron emits theopen-urlevent very early — before this point — so at3://...launch that triggers the initialopen-urlis dropped and the app opens without navigating to the deep link. Consider registering theopen-urllistener before these async initialization steps.🤖 Copy this AI Prompt to have your agent fix this: