fix(desktop): deliver each deep link once to its window (#61648)

This commit is contained in:
Luke Parker
2026-11-04 10:39:53 -06:00
committed by GitHub
Unverified
parent 0934293aa4
commit dd9a5ab6ab
15 changed files with 486 additions and 41 deletions
@@ -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
})
@@ -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 } })
})
})
@@ -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<Record<string, DeepLinkWindow>>
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: [] }
}
+2 -1
View File
@@ -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
}
+12 -2
View File
@@ -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),
@@ -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()
@@ -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<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
@@ -27,6 +29,7 @@ export const IpcServerProtocolLive = Layer.unwrap(
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
const bindings = new Map<number, PortBinding>()
const senderBindings = new Map<number, number>()
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)
}
},
}),
)
}),
+2
View File
@@ -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)
+7 -26
View File
@@ -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<void>
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) {
+9 -3
View File
@@ -45,7 +45,7 @@ const registry = createWindowRegistry<BrowserWindow>({
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 }
+3 -2
View File
@@ -15,7 +15,8 @@ import type {
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
consumeInitialDeepLinks(): Promise<string[]>
readyDeepLinks(): void
acknowledgeDeepLinks(attempt: number): void
isFirstLaunchOnboardingPending(): Promise<boolean>
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
@@ -35,7 +36,7 @@ export type ElectronAPI = {
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
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<string | string[] | null>
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
readPickedFile(token: string, path: string): Promise<ArrayBuffer>
+3 -2
View File
@@ -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),
@@ -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[]) {
+7 -3
View File
@@ -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,
@@ -4,6 +4,7 @@ import { Rpc, RpcGroup } from "effect/rpc"
import { PluginInstalled, PluginMenubarItem, PluginPayload, PluginGuis } from "./plugins"
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
attempt: Schema.Number,
urls: Schema.Array(Schema.String),
}) {}