diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9ac4f1154e91..7a77fbdf1787 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -18,6 +18,7 @@ import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/lang import { ServerConnection, ServersProvider } from "@/runtime/server/registry" import { SettingsProvider } from "@/settings/model" import { TabsProvider } from "@/shell/tabs/tabs" +import { SessionLinks } from "@/shell/links/links" import { ErrorPage } from "@/shell/errors/error" import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes" import { ExtensionRoot } from "@/runtime/extension/root" @@ -104,6 +105,7 @@ export function AppInterface(props: { + {props.children} {rootProps.children} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 61adb92dd10a..b10f4f98c468 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -20,6 +20,7 @@ export const dict = { "voice.failure.connection": "Couldn’t connect the call.", ...DESKTOP_NATIVE_ENGLISH, "session.location.unavailable": "Session location unavailable", + "link.session.notFound": "Session not found on your servers", "session.location.description": "Choose another directory to continue this session.", "session.location.choose": "Choose directory", "session.location.worktree": "Choose worktree", diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index 47b9f02f00e3..2eb69c52768c 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -103,6 +103,9 @@ type PlatformBase = { /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Receive `opencode://` links the OS opened in this window, until the returned function is called (desktop only) */ + onOpenLink?(callback: (url: string) => void): () => void + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/shell/links/link.test.ts b/packages/app/src/shell/links/link.test.ts new file mode 100644 index 000000000000..549fdfa68690 --- /dev/null +++ b/packages/app/src/shell/links/link.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { parseSessionLink } from "./link" + +const session = "ses_efcd8972fffeT5zZbj9YtIl3LU" + +describe("parseSessionLink", () => { + test("reads a session and its server hint", () => { + expect(parseSessionLink(`opencode://session/${session}`)).toEqual({ sessionID: session }) + expect(parseSessionLink(`opencode://session/${session}?server=https://devbox.example.com:4096/`)).toEqual({ + sessionID: session, + server: "https://devbox.example.com:4096", + }) + expect(parseSessionLink(`opencode://session/${session}?server=sidecar`)).toEqual({ + sessionID: session, + server: "sidecar", + }) + }) + + test("rejects other links and malicious input", () => { + expect(parseSessionLink("opencode://console/authorized?window=a")).toBeUndefined() + expect(parseSessionLink(`https://session/${session}`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}/x`)).toBeUndefined() + expect(parseSessionLink("opencode://session/ses_..%2F..%2Fetc")).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?prompt=hi`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?server=a&server=b`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?server=javascript:alert(1)`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?server=https://user:pw@host`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?server=https://host/path`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}#x`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}\n`)).toBeUndefined() + }) + + test("rejects oversized links", () => { + expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(2100)}`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(257)}`)).toBeUndefined() + expect(parseSessionLink(`opencode://session/ses_${"a".repeat(65)}`)).toBeUndefined() + }) +}) diff --git a/packages/app/src/shell/links/link.ts b/packages/app/src/shell/links/link.ts new file mode 100644 index 000000000000..b7593ed39bd7 --- /dev/null +++ b/packages/app/src/shell/links/link.ts @@ -0,0 +1,47 @@ +export type SessionLink = { sessionID: string; server?: string } + +const MAX_LINK_LENGTH = 2048 + +const SESSION_ID = /^ses_[0-9A-Za-z]{8,64}$/ + +// A client-local server key: "sidecar" or an extension's `${extension}:${id}`. +const SERVER_KEY = /^[\w.@:-]{1,256}$/ + +/** + * Reads `opencode://session/[?server=]`. Desktop main validates links before they reach the app; + * this checks again because the app is the side that acts on them. + */ +export function parseSessionLink(value: string): SessionLink | undefined { + if (value.length > MAX_LINK_LENGTH || /[\s\u0000-\u001f\u007f]/.test(value)) return + const url = URL.parse(value) + + if (!url || url.protocol !== "opencode:" || url.hostname !== "session") return + + if (url.username || url.password || url.port || url.hash) return + const sessionID = url.pathname.slice(1) + + if (!SESSION_ID.test(sessionID)) return + const keys = [...url.searchParams.keys()] + + if (keys.length > 1 || keys.some((key) => key !== "server")) return + const hint = url.searchParams.get("server") + + if (hint === null) return { sessionID } + const server = serverHint(hint) + + if (!server) return + + return { sessionID, server } +} + +// An http(s) origin is the portable form; anything else is a key only the device that made it knows. +function serverHint(value: string) { + if (!/^https?:/i.test(value)) return SERVER_KEY.test(value) ? value : undefined + const url = URL.parse(value) + + if (!url || url.username || url.password || url.search || url.hash) return + + if (url.pathname !== "/" && url.pathname !== "") return + + return url.origin +} diff --git a/packages/app/src/shell/links/links.tsx b/packages/app/src/shell/links/links.tsx new file mode 100644 index 000000000000..3e0b59451b01 --- /dev/null +++ b/packages/app/src/shell/links/links.tsx @@ -0,0 +1,90 @@ +import { createEffect, createSignal, onCleanup } from "solid-js" +import { usePlatform } from "@/runtime/platform/platform" +import { useLanguage } from "@/runtime/i18n/language" +import { ServerConnection, useServers } from "@/runtime/server/registry" +import { useGlobal } from "@/runtime/server/runtime" +import { showToast } from "@/shell/notifications/toast" +import { openNotificationSession } from "@/shell/notifications/notification" +import { rootSession } from "@/shell/routes/session" +import { useTabs } from "@/shell/tabs/tabs" +import { parseSessionLink } from "./link" +import { resolveLinkSession } from "./resolve" + +const PROBE_TIMEOUT = 3_000 + +/** Opens `opencode://session/…` links the platform hands this window. Links only navigate. */ +export function SessionLinks() { + const platform = usePlatform() + const language = useLanguage() + const servers = useServers() + const global = useGlobal() + const tabs = useTabs() + const [pending, setPending] = createSignal([]) + + onCleanup(platform.onOpenLink?.((url) => setPending((urls) => [...urls, url])) ?? (() => {})) + + createEffect(() => { + if (!servers.hydrated() || !tabs.ready()) return + const urls = pending() + + if (!urls.length) return + setPending([]) + urls.forEach((url) => void open(url)) + }) + + async function open(url: string) { + const link = parseSessionLink(url) + + if (!link) return + + const found = await resolveLinkSession({ + sessionID: link.sessionID, + hint: link.server, + // The sidecar is asked first; it is the one most likely to have the session and the quickest to answer. + servers: global.servers + .list() + .toSorted((a, b) => Number(ServerConnection.builtin(b)) - Number(ServerConnection.builtin(a))) + .flatMap((conn) => { + const key = ServerConnection.key(conn) + const ctx = global.serverCtx(key) + + if (!ctx) return [] + + return [ + { + key, + url: conn.http.url, + get: (sessionID: string, signal: AbortSignal) => ctx.sdk.api.session.get({ sessionID }, { signal }), + }, + ] + }), + timeout: PROBE_TIMEOUT, + }) + + if (!found) { + showToast({ variant: "error", title: language.t("link.session.notFound") }) + + return + } + + const ctx = global.serverCtx(found.key) + ctx?.data.session.remember(found.session) + + // A subagent opens in its root session's tab, routed to the subagent. + const root = ctx + ? await rootSession(found.session, async (sessionID) => { + const cached = ctx.data.session.get(sessionID) + + if (cached) return cached + const ancestor = await ctx.sdk.api.session.get({ sessionID }) + ctx.data.session.remember(ancestor) + + return ancestor + }).catch(() => found.session) + : found.session + + openNotificationSession(tabs, found.key, root.id, found.session) + } + + return null +} diff --git a/packages/app/src/shell/links/resolve.test.ts b/packages/app/src/shell/links/resolve.test.ts new file mode 100644 index 000000000000..10a4b12659fe --- /dev/null +++ b/packages/app/src/shell/links/resolve.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import { resolveLinkSession, type LinkServer } from "./resolve" + +type Session = { id: string } + +function server(key: string, url: string, sessions: string[], calls: string[] = []): LinkServer { + return { + key, + url, + get: async (sessionID) => { + calls.push(key) + + if (sessions.includes(sessionID)) return { id: sessionID } + throw new Error("NotFound") + }, + } +} + +// Answers only when its signal aborts, like a server that is down. +function hanging(key: string, calls: string[] = []): LinkServer { + return { + key, + url: "http://down.example.com", + get: (_sessionID, signal) => { + calls.push(key) + + return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason))) + }, + } +} + +describe("resolveLinkSession", () => { + test("asks only the server the link names when it has the session", async () => { + const calls: string[] = [] + + const servers = [ + server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls), + server("https://devbox.example.com:4096", "https://devbox.example.com:4096", ["ses_a"], calls), + ] + + expect( + await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 }), + ).toEqual({ key: "https://devbox.example.com:4096", session: { id: "ses_a" } }) + expect(calls).toEqual(["https://devbox.example.com:4096"]) + }) + + test("matches a named server by key or by origin", async () => { + const servers = [server("ssh:devbox", "http://127.0.0.1:52000/", ["ses_a"])] + + expect((await resolveLinkSession({ sessionID: "ses_a", hint: "ssh:devbox", servers, timeout: 100 }))?.key).toBe( + "ssh:devbox", + ) + expect( + (await resolveLinkSession({ sessionID: "ses_a", hint: "http://127.0.0.1:52000", servers, timeout: 100 }))?.key, + ).toBe("ssh:devbox") + }) + + test("finds the session on another server when the named one is unknown or lacks it", async () => { + const servers = [ + server("sidecar", "http://127.0.0.1:4096", ["ses_a"]), + server("https://other.example.com", "https://other.example.com", []), + ] + + expect( + (await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 })) + ?.key, + ).toBe("sidecar") + expect( + (await resolveLinkSession({ sessionID: "ses_a", hint: "https://other.example.com", servers, timeout: 100 }))?.key, + ).toBe("sidecar") + expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 100 }))?.key).toBe("sidecar") + }) + + test("does not wait for a server that is down", async () => { + const calls: string[] = [] + const servers = [hanging("ssh:down", calls), server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls)] + const started = Date.now() + + expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 5_000 }))?.key).toBe("sidecar") + expect(Date.now() - started).toBeLessThan(1_000) + expect(calls).toEqual(["ssh:down", "sidecar"]) + }) + + test("gives up when no server has the session", async () => { + const servers = [hanging("ssh:down"), server("sidecar", "http://127.0.0.1:4096", [])] + + expect(await resolveLinkSession({ sessionID: "ses_a", hint: "sidecar", servers, timeout: 20 })).toBeUndefined() + expect(await resolveLinkSession({ sessionID: "ses_a", servers: [], timeout: 20 })).toBeUndefined() + }) +}) diff --git a/packages/app/src/shell/links/resolve.ts b/packages/app/src/shell/links/resolve.ts new file mode 100644 index 000000000000..812bfceaea69 --- /dev/null +++ b/packages/app/src/shell/links/resolve.ts @@ -0,0 +1,40 @@ +export type LinkServer = { + key: Key + url: string + get: (sessionID: string, signal: AbortSignal) => Promise +} + +/** + * Finds which server has a linked session. A server the link names (by key or origin) is asked first; otherwise every + * server is asked at once and the first to have it wins, since session IDs are unique. The same server can be listed + * under different keys on different clients (the sidecar here, an http server on another Mac), which is why a named + * server that is not listed still finds the session. Never adds a server. + */ +export async function resolveLinkSession(input: { + sessionID: string + hint?: string + servers: LinkServer[] + timeout: number +}) { + const named = input.hint + ? input.servers.find((server) => server.key === input.hint || URL.parse(server.url)?.origin === input.hint) + : undefined + + const hit = named ? await probe(named, input.sessionID, input.timeout) : undefined + + if (hit) return hit + const rest = input.servers.filter((server) => server !== named) + + return Promise.any( + rest.map((server) => + probe(server, input.sessionID, input.timeout).then((found) => found ?? Promise.reject(new Error("not found"))), + ), + ).catch(() => undefined) +} + +function probe(server: LinkServer, sessionID: string, timeout: number) { + return server.get(sessionID, AbortSignal.timeout(timeout)).then( + (session) => ({ key: server.key, session }), + () => undefined, + ) +} diff --git a/packages/app/src/shell/notifications/notification.test.ts b/packages/app/src/shell/notifications/notification.test.ts index d14f6d574404..f804376a3583 100644 --- a/packages/app/src/shell/notifications/notification.test.ts +++ b/packages/app/src/shell/notifications/notification.test.ts @@ -1,16 +1,16 @@ import { expect, test } from "bun:test" -import type { ServerConnection } from "@/runtime/server/registry" +import { ServerConnection } from "@/runtime/server/registry" import type { Tab } from "@/shell/tabs/tabs" import { openNotificationSession } from "./notification" -test("opens notification sessions through the tab router", () => { - const server = "local\nhttp://localhost:4096" as ServerConnection.Key - const tab = { type: "session" as const, server, sessionId: "session-1" } +test("opens notification sessions through the tab router, leaving a subagent the tab showed", () => { + const server = ServerConnection.Key.make("local\nhttp://localhost:4096") + const tab = { type: "session" as const, server, sessionId: "session-1", routeSessionId: "child" } const calls: string[] = [] const tabs = { - addSessionTab: (input: Omit) => { - calls.push(`add:${input.sessionId}`) + addSessionTab: (input: { sessionId: string; routeSessionId?: string }) => { + calls.push(`add:${input.sessionId}>${input.routeSessionId}`) return tab }, @@ -18,11 +18,35 @@ test("opens notification sessions through the tab router", () => { calls.push(`route:${sessionID}`) }, select: (input: Tab) => { - calls.push(`select:${input.type === "session" ? input.sessionId : input.draftID}`) + calls.push(`select:${input.type === "session" ? `${input.sessionId}>${input.routeSessionId}` : input.draftID}`) }, } openNotificationSession(tabs, server, "session-1") - expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"]) + expect(calls).toEqual(["add:session-1>undefined", "route:session-1", "select:session-1>undefined"]) +}) + +test("opens a child session inside its root session's tab, routed from the moment the tab is added", () => { + const server = ServerConnection.Key.make("sidecar") + const tab = { type: "session" as const, server, sessionId: "root" } + const calls: string[] = [] + + const tabs = { + addSessionTab: (input: { sessionId: string; routeSessionId?: string }) => { + calls.push(`add:${input.sessionId}>${input.routeSessionId}`) + + return tab + }, + rememberSessionRoute: (_tab: typeof tab, sessionID: string, parentID?: string) => { + calls.push(`route:${sessionID}:${parentID}`) + }, + select: (input: Tab) => { + calls.push(`select:${input.type === "session" ? `${input.sessionId}>${input.routeSessionId}` : input.draftID}`) + }, + } + + openNotificationSession(tabs, server, "root", { id: "child", parentID: "root" }) + + expect(calls).toEqual(["add:root>child", "route:child:root", "select:root>child"]) }) diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx index b841a201630f..5dcd7fced31f 100644 --- a/packages/app/src/shell/notifications/notification.tsx +++ b/packages/app/src/shell/notifications/notification.tsx @@ -17,12 +17,23 @@ import { useServer } from "@/runtime/server/current" type NotificationTabs = Pick, "addSessionTab" | "rememberSessionRoute" | "select"> -export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) +/** Opens a session's tab, or reuses it, and selects it; `route` shows a child session inside its root's tab. */ +export function openNotificationSession( + tabs: NotificationTabs, + server: ServerConnection.Key, + sessionID: string, + route?: { id: string; parentID?: string }, +) { + const child = route && route.id !== sessionID ? route : undefined + const routed = { routeSessionId: child?.id, routeParentId: child?.parentID } + // A new tab carries its route from the start, since it joins the store in a transition; an existing + // tab is returned with its old route, so the route is remembered and set on the value selected. + const tab = tabs.addSessionTab({ server, sessionId: sessionID, ...(child && routed) }) if (tab.type !== "session") return - tabs.rememberSessionRoute(tab, sessionID) - tabs.select(tab) + + tabs.rememberSessionRoute(tab, child?.id ?? sessionID, child?.parentID) + tabs.select({ ...tab, ...routed }) } /** A session is unread when its latest terminal execution is newer than the last one any client viewed. */ diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index ba7836cbfe30..9d0cc322d103 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -7,8 +7,5 @@ declare global { interface Window { electron: ElectronNative - __OPENCODE__?: { - deepLinks?: string[] - } } } diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 2baf88b6c10a..84d8b22cdb24 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -4,6 +4,7 @@ import { setPinchZoomEnabled, setZoomLevel, webviewZoom, zoomLevel } from "../wi import { windowFullscreen } from "../window/fullscreen" import { DragCancelEvent } from "../../shared/ipc-transport" import { createExtensionBridge } from "../extensions" +import { onOpenLink } from "../startup/deep-links" import { createDesktopFiles } from "./files" import { createDesktopMenuAction } from "./menu" import { createDesktopNotify } from "./notifications" @@ -48,6 +49,7 @@ export function createDesktopPlatform(api: ElectronAPI, windowState: DesktopWind return () => window.removeEventListener(DragCancelEvent, callback) }, + onOpenLink, runDesktopMenuAction: createDesktopMenuAction(api), checkAppExists: async (appName) => { return api.checkAppExists(appName) diff --git a/packages/desktop/src/renderer/startup/deep-links.ts b/packages/desktop/src/renderer/startup/deep-links.ts index fff01a8413fe..33acb6ea0eeb 100644 --- a/packages/desktop/src/renderer/startup/deep-links.ts +++ b/packages/desktop/src/renderer/startup/deep-links.ts @@ -1,15 +1,24 @@ import type { ElectronAPI } from "../api-types" -const deepLinkEvent = "opencode:deep-link" +let receiver: ((url: string) => void) | undefined +const buffered: string[] = [] + +// Starts before the app mounts so a link that launched the window waits until the app subscribes. export function startDeepLinks(api: ElectronAPI) { - void api.consumeInitialDeepLinks().then(emitDeepLinks) - api.onDeepLink(emitDeepLinks) + api.onDeepLink(deliver) + void api.consumeInitialDeepLinks().then(deliver) +} + +export function onOpenLink(receive: (url: string) => void) { + receiver = receive + buffered.splice(0).forEach(receive) + + return () => { + if (receiver === receive) receiver = undefined + } } -function emitDeepLinks(urls: string[]) { - if (urls.length === 0) return - window.__OPENCODE__ ??= {} - window.__OPENCODE__.deepLinks = [...(window.__OPENCODE__.deepLinks ?? []), ...urls] - window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } })) +function deliver(urls: string[]) { + urls.forEach((url) => (receiver ? receiver(url) : buffered.push(url))) }