diff --git a/packages/desktop/src/main/deep-links/index.ts b/packages/desktop/src/main/deep-links/index.ts new file mode 100644 index 00000000000..ccd9b1e39b1 --- /dev/null +++ b/packages/desktop/src/main/deep-links/index.ts @@ -0,0 +1,52 @@ +export * as DeepLinks from "./index" + +import type { BrowserWindow } from "electron" +import { Effect } from "effect" +import { DeepLinksOpened } from "../../shared/ipc-rpc/events" +import { emitIpcEvent } from "../ipc-events" +import { getWindowByID, onMainWindow } from "../windows" +import { createDeepLinkState, transitionDeepLinks, type DeepLinkEvent } from "./machine" + +export interface Interface { + readonly receive: (urls: readonly string[]) => void + readonly ready: (windowID: string, clientID: number) => void + readonly acknowledge: (clientID: number, attempt: number) => void + readonly disconnect: (clientID: number) => void +} + +/** Routes deep links to app windows through the delivery machine for as long as the caller's scope lives. */ +export const make = Effect.fn("DeepLinks.make")(function* (focus: (win: BrowserWindow | null) => void) { + let state = createDeepLinkState() + + const dispatch = (event: DeepLinkEvent): void => { + const next = transitionDeepLinks(state, event) + // Commit before running commands: focusing a window dispatches its focus event synchronously. + state = next.state + next.commands.forEach((command) => { + if (command.type === "window.focus") return focus(getWindowByID(command.windowID)) + const win = getWindowByID(command.windowID) + const opened = new DeepLinksOpened({ attempt: command.attempt, urls: command.urls }) + const sent = !!win && emitIpcEvent(win.webContents, opened) + + // A renderer without an event stream is gone, so its links wait for the next one. + if (!sent) dispatch({ type: "client.disconnected", clientID: command.clientID }) + }) + } + + const stop = onMainWindow((win, windowID) => { + dispatch({ type: "window.opened", windowID }) + + if (win.isFocused()) dispatch({ type: "window.focused", windowID }) + win.on("focus", () => dispatch({ type: "window.focused", windowID })) + win.once("closed", () => dispatch({ type: "window.closed", windowID })) + }) + + yield* Effect.addFinalizer(() => Effect.sync(stop)) + + return { + receive: (urls) => dispatch({ type: "links.received", urls }), + ready: (windowID, clientID) => dispatch({ type: "window.ready", windowID, clientID }), + acknowledge: (clientID, attempt) => dispatch({ type: "delivery.acknowledged", clientID, attempt }), + disconnect: (clientID) => dispatch({ type: "client.disconnected", clientID }), + } satisfies Interface +}) diff --git a/packages/desktop/src/main/deep-links/machine.test.ts b/packages/desktop/src/main/deep-links/machine.test.ts new file mode 100644 index 00000000000..263286efcd4 --- /dev/null +++ b/packages/desktop/src/main/deep-links/machine.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test" +import { + createDeepLinkState, + transitionDeepLinks, + type DeepLinkCommand, + type DeepLinkEvent, + type DeepLinkState, +} from "./machine" + +const oauth = "opencode://console/authorized?window=a" + +const oauthB = "opencode://console/authorized?window=b" + +const project = "opencode://open?project=/repo" + +// Folds events through the machine and keeps the commands of each step. +function replay(events: readonly DeepLinkEvent[], from = createDeepLinkState()) { + return events.reduce<{ state: DeepLinkState; commands: (readonly DeepLinkCommand[])[] }>( + (result, event) => { + const next = transitionDeepLinks(result.state, event) + + return { state: next.state, commands: [...result.commands, next.commands] } + }, + { state: from, commands: [] }, + ) +} + +const ready = (windowID: string, clientID: number): DeepLinkEvent[] => [ + { type: "window.opened", windowID }, + { type: "window.ready", windowID, clientID }, +] + +describe("deep link delivery", () => { + test("never hands an acknowledged link to a reloaded or new window", () => { + const live = replay([ + ...ready("a", 1), + { type: "window.focused", windowID: "a" }, + { type: "links.received", urls: [oauth, project] }, + ]) + + expect(live.commands.at(-1)).toEqual([ + { type: "links.deliver", windowID: "a", clientID: 1, attempt: 1, urls: [oauth, project] }, + { type: "window.focus", windowID: "a" }, + ]) + + // View > Reload, then a new window: on dev both booted renderers drained the shared queue again. + const after = replay( + [ + { type: "delivery.acknowledged", clientID: 1, attempt: 1 }, + { type: "client.disconnected", clientID: 1 }, + { type: "window.ready", windowID: "a", clientID: 2 }, + ...ready("b", 3), + ], + live.state, + ) + + expect(after.commands.flat()).toEqual([]) + expect(after.state.links).toEqual([]) + }) + + test("queues a link until the first window is ready", () => { + const result = replay([{ type: "links.received", urls: [project] }, { type: "window.opened", windowID: "a" }]) + expect(result.commands.flat()).toEqual([]) + expect(result.state.links).toEqual([{ status: "queued", url: project, target: undefined }]) + + expect(transitionDeepLinks(result.state, { type: "window.ready", windowID: "a", clientID: 1 }).commands).toEqual([ + { type: "links.deliver", windowID: "a", clientID: 1, attempt: 1, urls: [project] }, + ]) + }) + + test("waits for the window a console return names instead of the focused one", () => { + const result = replay([ + { type: "window.opened", windowID: "a" }, + ...ready("b", 2), + { type: "window.focused", windowID: "b" }, + { type: "links.received", urls: [oauth] }, + ]) + + expect(result.commands.at(-1)).toEqual([{ type: "window.focus", windowID: "a" }]) + + expect(transitionDeepLinks(result.state, { type: "window.ready", windowID: "a", clientID: 1 }).commands).toEqual([ + { type: "links.deliver", windowID: "a", clientID: 1, attempt: 1, urls: [oauth] }, + ]) + }) + + test("splits one arrival into a batch and attempt per window", () => { + const result = replay([ + ...ready("b", 2), + ...ready("c", 3), + { type: "window.focused", windowID: "c" }, + { type: "links.received", urls: [project, oauthB] }, + ]) + + expect(result.commands.at(-1)).toEqual([ + { type: "links.deliver", windowID: "c", clientID: 3, attempt: 1, urls: [project] }, + { type: "links.deliver", windowID: "b", clientID: 2, attempt: 2, urls: [oauthB] }, + { type: "window.focus", windowID: "b" }, + ]) + }) + + test("delivers an unacknowledged link again to the next renderer and ignores stale replies", () => { + const delivered = replay([...ready("a", 1), { type: "links.received", urls: [project] }]) + + const reloaded = replay( + [ + { type: "client.disconnected", clientID: 1 }, + { type: "window.ready", windowID: "a", clientID: 2 }, + ], + delivered.state, + ) + + expect(reloaded.commands).toEqual([ + [], + [{ type: "links.deliver", windowID: "a", clientID: 2, attempt: 2, urls: [project] }], + ]) + + const stale: DeepLinkEvent[] = [ + { type: "delivery.acknowledged", clientID: 1, attempt: 1 }, + { type: "delivery.acknowledged", clientID: 2, attempt: 1 }, + { type: "delivery.acknowledged", clientID: 1, attempt: 2 }, + { type: "client.disconnected", clientID: 1 }, + { type: "window.ready", windowID: "a", clientID: 2 }, + ] + + stale.forEach((event) => { + const next = transitionDeepLinks(reloaded.state, event) + expect(next.state).toBe(reloaded.state) + expect(next.commands).toEqual([]) + }) + }) + + test("sends links for a closed window to the focused one, as before", () => { + const result = replay([ + { type: "window.opened", windowID: "a" }, + ...ready("b", 2), + { type: "links.received", urls: [oauth] }, + { type: "window.closed", windowID: "a" }, + ]) + + expect(result.commands.at(-1)).toEqual([ + { type: "links.deliver", windowID: "b", clientID: 2, attempt: 1, urls: [oauth] }, + ]) + }) + + test("follows focus to a ready window while the focused one loads", () => { + const result = replay([ + { type: "window.opened", windowID: "a" }, + { type: "window.focused", windowID: "a" }, + ...ready("b", 2), + { type: "links.received", urls: [project] }, + { type: "window.focused", windowID: "b" }, + ]) + + expect(result.commands.at(-2)).toEqual([{ type: "window.focus", windowID: "a" }]) + expect(result.commands.at(-1)).toEqual([ + { type: "links.deliver", windowID: "b", clientID: 2, attempt: 1, urls: [project] }, + ]) + }) + + test("adopts a window whose renderer reports ready before it registers", () => { + const result = replay([ + { type: "window.ready", windowID: "a", clientID: 1 }, + { type: "window.opened", windowID: "a" }, + ]) + + expect(result.state.windows).toEqual({ a: { status: "ready", clientID: 1 } }) + }) +}) diff --git a/packages/desktop/src/main/deep-links/machine.ts b/packages/desktop/src/main/deep-links/machine.ts new file mode 100644 index 00000000000..0ef982042d6 --- /dev/null +++ b/packages/desktop/src/main/deep-links/machine.ts @@ -0,0 +1,198 @@ +import { consoleReturnWindow } from "../lifecycle/deep-link" + +export type DeepLinkWindow = { readonly status: "loading" } | { readonly status: "ready"; readonly clientID: number } + +// `target` is the window a console return names; a link without one, or whose window is gone, opens in the +// preferred window. +export type DeepLink = + | { readonly status: "queued"; readonly url: string; readonly target: string | undefined } + | { + readonly status: "delivered" + readonly url: string + readonly target: string | undefined + readonly clientID: number + readonly attempt: number + } + +export type DeepLinkState = { + readonly windows: Readonly> + readonly focusedWindowID: string | undefined + readonly links: readonly DeepLink[] + readonly nextAttempt: number +} + +export type DeepLinkEvent = + | { readonly type: "links.received"; readonly urls: readonly string[] } + | { readonly type: "window.opened"; readonly windowID: string } + | { readonly type: "window.focused"; readonly windowID: string } + | { readonly type: "window.ready"; readonly windowID: string; readonly clientID: number } + | { readonly type: "window.closed"; readonly windowID: string } + | { readonly type: "client.disconnected"; readonly clientID: number } + | { readonly type: "delivery.acknowledged"; readonly clientID: number; readonly attempt: number } + +export type DeepLinkCommand = + | { + readonly type: "links.deliver" + readonly windowID: string + readonly clientID: number + readonly attempt: number + readonly urls: readonly string[] + } + | { readonly type: "window.focus"; readonly windowID: string } + +export type DeepLinkTransition = { + readonly state: DeepLinkState + readonly commands: readonly DeepLinkCommand[] +} + +export function createDeepLinkState(): DeepLinkState { + return { windows: {}, focusedWindowID: undefined, links: [], nextAttempt: 1 } +} + +export function transitionDeepLinks(state: DeepLinkState, event: DeepLinkEvent): DeepLinkTransition { + switch (event.type) { + case "links.received": { + if (event.urls.length === 0) return unchanged(state) + const links = event.urls.map((url): DeepLink => ({ status: "queued", url, target: consoleReturnWindow(url) })) + const focus = links.find((link) => link.target && state.windows[link.target])?.target ?? preferredWindowID(state) + const next = drain({ ...state, links: [...state.links, ...links] }) + + return { + state: next.state, + commands: focus ? [...next.commands, { type: "window.focus", windowID: focus }] : next.commands, + } + } + + case "window.opened": { + if (state.windows[event.windowID]) return unchanged(state) + + return drain({ ...state, windows: { ...state.windows, [event.windowID]: { status: "loading" } } }) + } + + case "window.focused": { + if (!state.windows[event.windowID] || state.focusedWindowID === event.windowID) return unchanged(state) + + return drain({ ...state, focusedWindowID: event.windowID }) + } + + case "window.ready": { + const current = state.windows[event.windowID] + + if (current?.status === "ready" && current.clientID === event.clientID) return unchanged(state) + + return drain({ + ...state, + windows: { ...state.windows, [event.windowID]: { status: "ready", clientID: event.clientID } }, + links: current?.status === "ready" ? requeue(state.links, current.clientID) : state.links, + }) + } + + case "window.closed": { + const current = state.windows[event.windowID] + + if (!current) return unchanged(state) + + return drain({ + ...state, + windows: Object.fromEntries(Object.entries(state.windows).filter(([id]) => id !== event.windowID)), + focusedWindowID: state.focusedWindowID === event.windowID ? undefined : state.focusedWindowID, + links: current.status === "ready" ? requeue(state.links, current.clientID) : state.links, + }) + } + + case "client.disconnected": { + const windowID = Object.entries(state.windows).find( + ([, window]) => window.status === "ready" && window.clientID === event.clientID, + )?.[0] + + const delivered = state.links.some((link) => link.status === "delivered" && link.clientID === event.clientID) + + if (!windowID && !delivered) return unchanged(state) + + return drain({ + ...state, + windows: windowID ? { ...state.windows, [windowID]: { status: "loading" } } : state.windows, + links: requeue(state.links, event.clientID), + }) + } + + case "delivery.acknowledged": { + // An acknowledged link leaves the state, so no later renderer can receive it again. + const links = state.links.filter( + (link) => link.status !== "delivered" || link.clientID !== event.clientID || link.attempt !== event.attempt, + ) + + if (links.length === state.links.length) return unchanged(state) + + return { state: { ...state, links }, commands: [] } + } + } +} + +// The window an untargeted link opens in: the focused one, else the first open one. +function preferredWindowID(state: DeepLinkState) { + return state.focusedWindowID ?? Object.keys(state.windows)[0] +} + +// The ready window and renderer a link goes to now, if any. +function recipientOf(state: DeepLinkState, link: DeepLink) { + const windowID = link.target && state.windows[link.target] ? link.target : preferredWindowID(state) + const window = windowID ? state.windows[windowID] : undefined + + if (!windowID || window?.status !== "ready") return + + return { windowID, clientID: window.clientID } +} + +// Sends every queued link whose window is ready, one batch and attempt per window. +function drain(state: DeepLinkState): DeepLinkTransition { + const routed = state.links.map((link) => ({ + link, + recipient: link.status === "queued" ? recipientOf(state, link) : undefined, + })) + + const recipients = routed.flatMap((item) => (item.recipient ? [item.recipient] : [])) + + const batches = recipients + .filter((recipient, index) => recipients.findIndex((other) => other.windowID === recipient.windowID) === index) + .map((recipient, index) => ({ + ...recipient, + attempt: state.nextAttempt + index, + urls: routed.flatMap((item) => (item.recipient?.windowID === recipient.windowID ? [item.link.url] : [])), + })) + + if (batches.length === 0) return { state, commands: [] } + + const links = routed.map((item): DeepLink => { + const batch = batches.find((candidate) => candidate.windowID === item.recipient?.windowID) + + if (!batch) return item.link + + return { + status: "delivered", + url: item.link.url, + target: item.link.target, + clientID: batch.clientID, + attempt: batch.attempt, + } + }) + + return { + state: { ...state, links, nextAttempt: state.nextAttempt + batches.length }, + commands: batches.map((batch): DeepLinkCommand => ({ type: "links.deliver", ...batch })), + } +} + +// A renderer that went away without acknowledging may not have handled its links, so they wait for the next one. +function requeue(links: readonly DeepLink[], clientID: number) { + return links.map( + (link): DeepLink => + link.status === "delivered" && link.clientID === clientID + ? { status: "queued", url: link.url, target: link.target } + : link, + ) +} + +function unchanged(state: DeepLinkState): DeepLinkTransition { + return { state, commands: [] } +} diff --git a/packages/desktop/src/main/ipc-events.ts b/packages/desktop/src/main/ipc-events.ts index c1119b80c4d..ad5d4e73861 100644 --- a/packages/desktop/src/main/ipc-events.ts +++ b/packages/desktop/src/main/ipc-events.ts @@ -23,8 +23,9 @@ export function ipcEventStream(senderId: number) { return queue ? Stream.fromQueue(queue) : Stream.empty } +/** Returns false when the renderer has no event stream to receive the event. */ export function emitIpcEvent(sender: WebContents, event: DesktopEvent) { const queue = queues.get(sender.id) - if (queue) Queue.offerUnsafe(queue, event) + return queue ? Queue.offerUnsafe(queue, event) : false } diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index c2542481484..ba13c4807fe 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -16,7 +16,7 @@ import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../ import { BackgroundService } from "../service/background-service" import { DesktopCli } from "../service/desktop-cli" import { SidecarCredentials } from "../service/sidecar-credentials" -import { getLastFocusedWindow, setBackgroundColor } from "../windows" +import { getLastFocusedWindow, getWindowID, setBackgroundColor } from "../windows" import { sender } from "./context" export const appHandlers = AppRpcs.toLayer( @@ -31,7 +31,17 @@ export const appHandlers = AppRpcs.toLayer( return AppRpcs.of({ AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)), AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)), - AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks), + AppReadyDeepLinks: (_request, context) => + Effect.sync(() => { + const contents = sender(handoff, context) + const win = BrowserWindow.fromWebContents(contents) + // Only an app window's own renderer receives deep links, not one embedded in it. + const id = win?.webContents === contents ? getWindowID(win) : undefined + + if (id) lifecycle.deepLinks.ready(id, context.client.id) + }), + AppAcknowledgeDeepLinks: ({ attempt }, context) => + Effect.sync(() => lifecycle.deepLinks.acknowledge(context.client.id, attempt)), AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending, AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) => finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie), diff --git a/packages/desktop/src/main/ipc-transport.test.ts b/packages/desktop/src/main/ipc-transport.test.ts index a4d29eb70a5..5274ffe14a4 100644 --- a/packages/desktop/src/main/ipc-transport.test.ts +++ b/packages/desktop/src/main/ipc-transport.test.ts @@ -77,6 +77,8 @@ describe("desktop RPC transport", () => { const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive)) const runtime = ManagedRuntime.make(live) const handoff = await runtime.runPromise(IpcPortHandoff) + const disconnected: number[] = [] + handoff.onDisconnect((clientId) => disconnected.push(clientId)) const first = new MessageChannel() const second = new MessageChannel() handoff.bind(sender(1), serverPort(first.port1)) @@ -108,6 +110,8 @@ describe("desktop RPC transport", () => { expect(reloadedFocused.exit).toEqual(success(true)) expect(stillUnfocused.exit).toEqual(success(false)) + // Deep link delivery returns unacknowledged links to the queue when a reload replaces a renderer's port. + expect(disconnected).toEqual([0]) for (const port of [first.port2, second.port2, reloaded.port2]) port.close() await runtime.dispose() diff --git a/packages/desktop/src/main/ipc-transport.ts b/packages/desktop/src/main/ipc-transport.ts index 3287d2a99f1..0797494e856 100644 --- a/packages/desktop/src/main/ipc-transport.ts +++ b/packages/desktop/src/main/ipc-transport.ts @@ -15,6 +15,8 @@ type PortBinding = { type Handoff = { readonly bind: (sender: WebContents, port: MessagePortMain) => void readonly sender: (clientId: number) => WebContents | undefined + /** Runs after a renderer port closes or is replaced, before a replacement port for its sender binds. */ + readonly onDisconnect: (listener: (clientId: number) => void) => () => void } export class IpcPortHandoff extends Context.Service()("opencode/desktop/IpcPortHandoff") {} @@ -27,6 +29,7 @@ export const IpcServerProtocolLive = Layer.unwrap( const handoffs = yield* Queue.unbounded() const bindings = new Map() const senderBindings = new Map() + const disconnected = new Set<(clientId: number) => void>() const protocol = Layer.effect( RpcServer.Protocol, @@ -49,6 +52,7 @@ export const IpcServerProtocolLive = Layer.unwrap( binding.sender.off("destroyed", binding.onClose) yield* binding.unbindEvents binding.port.close() + disconnected.forEach((listener) => listener(id)) Queue.offerUnsafe(disconnects, id) }) @@ -118,6 +122,13 @@ export const IpcServerProtocolLive = Layer.unwrap( Queue.offerUnsafe(handoffs, [sender, port]) }, sender: (clientId) => bindings.get(clientId)?.sender, + onDisconnect: (listener) => { + disconnected.add(listener) + + return () => { + disconnected.delete(listener) + } + }, }), ) }), diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 6c68d43357b..cdf35dbee1c 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -82,6 +82,8 @@ export const registerIpcHandlers = Effect.gen(function* () { } const onWindowCreated = (_event: Electron.Event, win: BrowserWindow) => wire(win) + const stopDisconnects = handoff.onDisconnect(lifecycle.deepLinks.disconnect) + yield* Effect.addFinalizer(() => Effect.sync(stopDisconnects)) yield* Effect.sync(() => { app.on("browser-window-created", onWindowCreated) diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index df043c9b96f..5274113a220 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -3,21 +3,19 @@ export * as ApplicationLifecycle from "./index" import { app, BrowserWindow } from "electron" import type { Event } from "electron" import { Context, Effect, Layer } from "effect" -import { DeepLinksOpened } from "../../shared/ipc-rpc/events" -import { emitIpcEvent } from "../ipc-events" +import { DeepLinks } from "../deep-links" import { DesktopLogging, scoped } from "../native/logging" import { DesktopStorage } from "../storage" import { safeWebContentsURL } from "../windows/state" -import { getLastFocusedWindow, getWindowByID, mainWindows, setAppQuitting, setRelaunchHandler } from "../windows" +import { getLastFocusedWindow, mainWindows, setAppQuitting, setRelaunchHandler } from "../windows" import { marks } from "./marks" import { initializeFirstLaunchOnboarding } from "./onboarding" import { Shutdown } from "./shutdown" -import { consoleReturnWindow } from "./deep-link" export interface Interface { readonly relaunch: () => void readonly prepareToRestart: Effect.Effect - readonly consumeInitialDeepLinks: () => string[] + readonly deepLinks: DeepLinks.Interface readonly createWindow: () => BrowserWindow readonly restoreWindows: () => BrowserWindow[] } @@ -32,7 +30,6 @@ const runtime = Layer.effect( const windows = yield* mainWindows() const createWindow = windows.create const restoreWindows = windows.restore - const pendingDeepLinks: string[] = [] let shutdownReady = false let quitRequested = false @@ -53,23 +50,7 @@ const runtime = Layer.effect( win.focus() } - const emitDeepLinks = (urls: string[]) => { - if (!urls.length) return - pendingDeepLinks.push(...urls) - - const target = urls.flatMap((url) => { - const id = consoleReturnWindow(url) - const win = id ? getWindowByID(id) : null - - return win ? [win] : [] - })[0] - - const win = target ?? getLastFocusedWindow() - - if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls })) - - return win - } + const deepLinks = yield* DeepLinks.make(focusWindow) const relaunch = () => { setAppQuitting() @@ -90,7 +71,7 @@ const runtime = Layer.effect( if (urls.length) { runFork(Effect.logInfo("deep link received via second-instance", { urls })) - focusWindow(emitDeepLinks(urls) ?? null) + deepLinks.receive(urls) } if (!urls.length) focusWindow(getLastFocusedWindow()) @@ -99,7 +80,7 @@ const runtime = Layer.effect( const openUrl = (event: Event, url: string) => { event.preventDefault() runFork(Effect.logInfo("deep link received via open-url", { url })) - focusWindow(emitDeepLinks([url]) ?? null) + deepLinks.receive([url]) } const beforeQuit = (event: Event) => { @@ -179,7 +160,7 @@ const runtime = Layer.effect( return Service.of({ relaunch, prepareToRestart, - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + deepLinks, createWindow, restoreWindows: () => { if (!windowsWired) { diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 892603f5a1c..bcb5298a1c9 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -45,7 +45,7 @@ const registry = createWindowRegistry({ write: (ids) => getStore().set(WINDOW_IDS_KEY, ids), }) -const opened = new Set<(win: BrowserWindow) => void>() +const opened = new Set<(win: BrowserWindow, id: string) => void>() let relaunchHandler = () => { setAppQuitting() @@ -99,13 +99,19 @@ export function getWindowByID(id: string) { return win } +/** The persistent id of an app window, or undefined for popups and other native windows. */ +export function getWindowID(win: BrowserWindow) { + // An open window's id stays in the persisted list until it closes. + return registry.persisted().find((id) => registry.get(id) === win) +} + /** App windows that host the renderer, excluding popups and other native windows. */ export function getMainWindows() { return registry.list().filter((win) => !win.isDestroyed()) } /** Runs for each app window as it registers, before its renderer loads. */ -export function onMainWindow(listener: (win: BrowserWindow) => void) { +export function onMainWindow(listener: (win: BrowserWindow, id: string) => void) { opened.add(listener) return () => { @@ -234,7 +240,7 @@ export const mainWindows = Effect.fn("Window.make")(function* () { ), ) }) - opened.forEach((listener) => listener(win)) + opened.forEach((listener) => listener(win, id)) } return { create, restore } diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index d67385c1a44..1fad0d233a8 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -15,7 +15,8 @@ import type { export type ElectronAPI = { awaitInitialization(): Promise reconnectService(): Promise - consumeInitialDeepLinks(): Promise + readyDeepLinks(): void + acknowledgeDeepLinks(attempt: number): void isFirstLaunchOnboardingPending(): Promise finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise checkAppExists(appName: string): Promise @@ -35,7 +36,7 @@ export type ElectronAPI = { getWindowBootstrap(): WindowBootstrap themeReady(): Promise onMenuCommand(cb: (id: string) => void): () => void - onDeepLink(cb: (urls: string[]) => void): () => void + onDeepLink(cb: (delivery: { attempt: number; urls: string[] }) => void): () => void openDirectoryPicker(opts?: DirectoryPickerOptions): Promise openFilePicker(opts?: FilePickerOptions): Promise readPickedFile(token: string, path: string): Promise diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index f1e994456a2..4c9ef44e3dd 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -23,7 +23,8 @@ const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object export const api: ElectronAPI = { awaitInitialization: () => invoke("AppAwaitInitialization"), reconnectService: () => invoke("AppReconnectService"), - consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable), + readyDeepLinks: () => send("AppReadyDeepLinks"), + acknowledgeDeepLinks: (attempt) => send("AppAcknowledgeDeepLinks", { attempt }), isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"), finishFirstLaunchOnboarding: (createDefaultProject) => invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }), @@ -54,7 +55,7 @@ export const api: ElectronAPI = { getWindowBootstrap: () => window.electron.bootstrap, themeReady: () => invoke("WindowThemeReady"), onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)), - onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))), + onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb({ attempt: event.attempt, urls: mutable(event.urls) })), openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable), openFilePicker: (opts) => invoke("FilesOpenFilePicker", { options: opts }).then(mutable), diff --git a/packages/desktop/src/renderer/startup/deep-links.ts b/packages/desktop/src/renderer/startup/deep-links.ts index fff01a8413f..b056bbd18d6 100644 --- a/packages/desktop/src/renderer/startup/deep-links.ts +++ b/packages/desktop/src/renderer/startup/deep-links.ts @@ -2,9 +2,14 @@ import type { ElectronAPI } from "../api-types" const deepLinkEvent = "opencode:deep-link" +// Main sends each link to one renderer at a time and forgets it once acknowledged, so a reload or a new window +// never handles it again. export function startDeepLinks(api: ElectronAPI) { - void api.consumeInitialDeepLinks().then(emitDeepLinks) - api.onDeepLink(emitDeepLinks) + api.onDeepLink((delivery) => { + emitDeepLinks(delivery.urls) + api.acknowledgeDeepLinks(delivery.attempt) + }) + api.readyDeepLinks() } function emitDeepLinks(urls: string[]) { diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index 9f3802806c3..1ddefa3c265 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -9,8 +9,11 @@ export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { succe export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData }) -export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", { - success: Schema.Array(Schema.String), +// The renderer listens for DeepLinksOpened before it reports ready, and acknowledges each delivery once handled. +export const AppReadyDeepLinks = Rpc.make("AppReadyDeepLinks") + +export const AppAcknowledgeDeepLinks = Rpc.make("AppAcknowledgeDeepLinks", { + payload: { attempt: Schema.Number }, }) export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", { @@ -69,7 +72,8 @@ export const AppRelaunch = Rpc.make("AppRelaunch") export const AppRpcs = RpcGroup.make( AppAwaitInitialization, AppReconnectService, - AppConsumeInitialDeepLinks, + AppReadyDeepLinks, + AppAcknowledgeDeepLinks, AppIsFirstLaunchOnboardingPending, AppFinishFirstLaunchOnboarding, AppCheckAppExists, diff --git a/packages/desktop/src/shared/ipc-rpc/events.ts b/packages/desktop/src/shared/ipc-rpc/events.ts index 584b3dde5b0..e90bc1044ac 100644 --- a/packages/desktop/src/shared/ipc-rpc/events.ts +++ b/packages/desktop/src/shared/ipc-rpc/events.ts @@ -4,6 +4,7 @@ import { Rpc, RpcGroup } from "effect/rpc" import { PluginInstalled, PluginMenubarItem, PluginPayload, PluginGuis } from "./plugins" export class DeepLinksOpened extends Schema.TaggedClass()("DeepLinksOpened", { + attempt: Schema.Number, urls: Schema.Array(Schema.String), }) {}