mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
fix(desktop): deliver each deep link once to its window (#61648)
This commit is contained in:
committed by
GitHub
Unverified
parent
0934293aa4
commit
dd9a5ab6ab
@@ -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: [] }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user