Compare commits

...

1 Commits

Author SHA1 Message Date
Brendan Allan 12d3824212 refactor(desktop): move IPC to Effect RPC 2026-08-18 11:13:53 +00:00
51 changed files with 1521 additions and 637 deletions
+8 -5
View File
@@ -2,7 +2,7 @@ import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./files/apps"
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
import { registerIpcHandlers } from "./ipc"
import {
acquireApplicationLock,
configureApplication,
@@ -21,6 +21,7 @@ import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-setti
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
import { startWsl } from "./wsl/start"
import { createDeferredWslIpc } from "./wsl/ipc"
const main = Effect.gen(function* () {
const logger = configureApplication()
@@ -33,6 +34,8 @@ const main = Effect.gen(function* () {
yield* prepareDesktop(logger)
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
const updaterIpc = createUpdaterIpc(updater)
const wslIpc = createDeferredWslIpc()
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
@@ -41,7 +44,7 @@ const main = Effect.gen(function* () {
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch: lifecycle.relaunch,
}
registerIpcHandlers({
const ipcDeps: Parameters<typeof registerIpcHandlers>[0] = {
relaunch: lifecycle.relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
@@ -66,8 +69,8 @@ const main = Effect.gen(function* () {
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menu)
},
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
}
yield* Effect.promise(() => registerIpcHandlers(ipcDeps, updaterIpc, wslIpc.ipc))
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
@@ -76,7 +79,7 @@ const main = Effect.gen(function* () {
logger.log("starting v2 background service")
const background = yield* Effect.promise(() => startBackgroundCli(logger))
const wsl = yield* Effect.promise(() => startWsl(background, logger))
registerWslIpcHandlers(wsl.ipc)
wslIpc.set(wsl.ipc)
wsl.start()
lifecycle.setWslShutdown(wsl.stop)
yield* Deferred.succeed(serverReady, {
+28
View File
@@ -0,0 +1,28 @@
import type { WebContents } from "electron"
import { Effect, Queue, Stream } from "effect"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
export function bindIpcEvents(senderId: number) {
const queue = Effect.runSync(Queue.unbounded<DesktopEvent>())
queues.set(senderId, queue)
return () => {
if (queues.get(senderId) === queue) queues.delete(senderId)
}
}
export function ipcEventStream(senderId: number) {
return Stream.unwrap(
Effect.gen(function* () {
const queue = queues.get(senderId) ?? (yield* Queue.unbounded<DesktopEvent>())
if (!queues.has(senderId)) queues.set(senderId, queue)
return Stream.fromQueue(queue)
}),
)
}
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
const queue = queues.get(sender.id)
if (queue) Queue.offerUnsafe(queue, event)
}
@@ -0,0 +1,64 @@
import { BrowserWindow } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import { Effect } from "effect"
import type { FatalRendererError, ServerReadyData } from "../../shared/ipc-contract"
import { AppRpcs } from "../../shared/ipc-rpc"
import { setForceFocus } from "../native/debug"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export type AppHandlerDeps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function appHandlers(deps: AppHandlerDeps) {
return AppRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return AppRpcs.of({
AppAwaitInitialization: () => Effect.promise(() => deps.awaitInitialization()),
AppConsumeInitialDeepLinks: () => promise(deps.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => promise(deps.getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => promise(() => deps.setDefaultServerUrl(url)),
AppIsFirstLaunchOnboardingPending: () => promise(deps.isFirstLaunchOnboardingPending),
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
promise(() => deps.finishFirstLaunchOnboarding(createDefaultProject)),
AppCheckAppExists: ({ appName }) => promise(() => deps.checkAppExists(appName)),
AppResolveAppPath: ({ appName }) => Effect.promise(() => deps.resolveAppPath(appName)),
AppSetBackgroundColor: ({ color }) => Effect.sync(() => deps.setBackgroundColor(color)),
AppExportDebugLogs: () => Effect.promise(() => deps.exportDebugLogs()),
AppSetForceFocus: ({ enabled }, context) =>
Effect.promise(() => setForceFocus(sender(handoff, context), enabled)),
AppRecordFatalRendererError: ({ error }) => promise(() => deps.recordFatalRendererError(error)),
AppSetNativeTranslations: ({ value }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
}),
AppRelaunch: () => Effect.sync(deps.relaunch),
})
}),
)
}
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.promise(async () => evaluate())
}
@@ -0,0 +1,9 @@
import type { IpcPortHandoff } from "../ipc-transport"
export type RpcContext = { readonly client: { readonly id: number } }
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
const contents = handoff.sender(context.client.id)
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
return contents
}
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -0,0 +1,40 @@
import { Effect } from "effect"
import { FileRpcs } from "../../shared/ipc-rpc"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export function fileHandlers(files: ReturnType<typeof createFileCapabilities>) {
return FileRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return FileRpcs.of({
FilesOpenDirectoryPicker: ({ options }) => Effect.promise(() => files.openDirectoryPicker(options)),
FilesOpenFilePicker: ({ options }, context) =>
Effect.promise(() =>
files.openFilePicker(
sender(handoff, context).id,
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
),
),
FilesReadPickedFile: ({ token, path }, context) =>
Effect.promise(
async () => new Uint8Array(await files.readPickedFile(sender(handoff, context).id, token, path)),
),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFilePicker: ({ options }) => Effect.promise(() => files.saveFilePicker(options)),
FilesOpenExternal: ({ url }) => Effect.sync(() => openExternalURL(url)),
FilesOpenLocalFile: ({ url }) => Effect.sync(() => openLocalFileURL(url)),
FilesOpenPath: ({ path, application }) =>
Effect.promise(async () => (await files.openPath(path, application)) ?? null),
FilesRevealPath: ({ path }) => Effect.promise(() => files.revealPath(path)),
FilesReadClipboardImage: () =>
Effect.sync(() => {
const image = files.readClipboardImage()
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
}),
})
}),
)
}
@@ -0,0 +1,26 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { MenuRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { runDesktopMenuAction } from "../native/menu-actions"
import { sender } from "./context"
export function menuHandlers(deps: {
readonly showUpdater: () => Promise<void> | void
readonly relaunch: () => void
}) {
return MenuRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return MenuRpcs.of({
MenuRunAction: ({ action }, context) =>
Effect.sync(() =>
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
}),
),
})
}),
)
}
@@ -0,0 +1,32 @@
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import type { createDesktopStorage } from "../storage"
export function storageHandlers(storage: ReturnType<typeof createDesktopStorage>) {
return StorageRpcs.toLayer(
Effect.succeed(
StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => Effect.sync(() => storage.deleteValue(name, key)),
StorageClear: ({ name }) => Effect.sync(() => storage.clear(name)),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,
),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
}),
),
)
}
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import { UpdaterRpcs } from "../../shared/ipc-rpc"
import type { UpdaterIpc } from "../updater"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export function updaterHandlers(updater: UpdaterIpc) {
return UpdaterRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return UpdaterRpcs.of({
UpdaterSubscribe: (_args, context) => Effect.sync(() => updater.subscribe(sender(handoff, context))),
UpdaterUnsubscribe: (_args, context) => Effect.sync(() => updater.unsubscribe(sender(handoff, context).id)),
UpdaterCheck: () => Effect.promise(() => updater.check()),
UpdaterInstall: () => Effect.promise(() => updater.install()),
})
}),
)
}
@@ -0,0 +1,45 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowGetFocused: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
WindowGetFullscreen: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
WindowSetFocus: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
WindowShow: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
WindowSetZoomFactor: ({ factor }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
contents.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(contents)
if (win) updateTitlebar(win)
}),
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
WindowSetTitlebar: ({ theme }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
})
}),
)
@@ -0,0 +1,28 @@
import { Effect } from "effect"
import { WslRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import type { WslIpc } from "../wsl/ipc"
import { sender } from "./context"
export function wslHandlers(wsl: WslIpc) {
return WslRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WslRpcs.of({
WslSubscribe: (_args, context) => Effect.sync(() => wsl.subscribe(sender(handoff, context))),
WslUnsubscribe: (_args, context) => Effect.sync(() => wsl.unsubscribe(sender(handoff, context).id)),
WslGetState: () => Effect.sync(() => wsl.getState()),
WslProbeRuntime: () => Effect.promise(() => wsl.probeRuntime()),
WslRefreshDistros: () => Effect.promise(() => wsl.refreshDistros()),
WslInstallWsl: () => Effect.promise(() => wsl.installWsl()),
WslInstallDistro: ({ name }) => Effect.promise(() => wsl.installDistro(name)),
WslProbeAddable: ({ distros }) => Effect.promise(() => wsl.probeAddable([...distros])),
WslInstallOpencode: ({ name }) => Effect.promise(() => wsl.installOpencode(name)),
WslOpenTerminal: ({ name }) => Effect.promise(() => wsl.openTerminal(name)),
WslAddServer: ({ distro }) => Effect.promise(() => wsl.addServer(distro)),
WslRemoveServer: ({ id }) => Effect.promise(() => wsl.removeServer(id)),
WslStartServer: ({ id }) => Effect.promise(() => wsl.startServer(id)),
})
}),
)
}
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { MessageChannel } from "node:worker_threads"
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
describe("desktop RPC transport", () => {
test("keeps multiple renderer ports independent", async () => {
const handlers = TestRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return TestRpcs.of({
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
})
}),
)
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const first = new MessageChannel()
const second = new MessageChannel()
handoff.bind(sender(1), serverPort(first.port1))
handoff.bind(sender(2), serverPort(second.port1))
const firstClient = makeClient(first.port2)
const secondClient = makeClient(second.port2)
const [focused, unfocused] = await Promise.all([callFocused(firstClient), callFocused(secondClient)])
expect(focused).toBe(true)
expect(unfocused).toBe(false)
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
const reloaded = new MessageChannel()
handoff.bind(sender(1), serverPort(reloaded.port1))
const reloadedClient = makeClient(reloaded.port2)
const [reloadedFocused, stillUnfocused] = await Promise.all([
callFocused(reloadedClient),
callFocused(secondClient),
])
expect(reloadedFocused).toBe(true)
expect(stillUnfocused).toBe(false)
await Promise.all([firstClient.dispose(), secondClient.dispose(), reloadedClient.dispose()])
await runtime.dispose()
})
})
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
const TestRpcs = RpcGroup.make(
Rpc.make("test.focused", { success: Schema.Boolean }),
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
class TestClient extends Context.Service<TestClient, TestRpcClient>()("opencode/desktop/TestClient") {}
function makeClient(port: MessagePort) {
return ManagedRuntime.make(
Layer.effect(TestClient, RpcClient.make(TestRpcs)).pipe(Layer.provide(clientProtocol(port))),
)
}
function callFocused(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.focused"]()
}),
)
}
function putBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>, data: Uint8Array) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.put"]({ data })
}),
)
}
function getBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.get"]()
}),
)
}
function firstEvent(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.events"]().pipe(Stream.runHead, Effect.map(Option.getOrThrow))
}),
)
}
function clientProtocol(port: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) =>
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
port.addEventListener("message", onMessage)
port.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
port.removeEventListener("message", onMessage)
port.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) port.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
function sender(id: number) {
const events = new EventEmitter()
return {
id,
isDestroyed: () => false,
once: events.once.bind(events),
off: events.off.bind(events),
} as unknown as WebContents
}
function serverPort(port: import("node:worker_threads").MessagePort) {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
return {
on(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.on(event, listener)
return
}
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
listeners.set(listener, wrapped)
port.on("message", wrapped)
},
off(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.off(event, listener)
return
}
const wrapped = listeners.get(listener)
if (wrapped) port.off("message", wrapped)
},
postMessage: port.postMessage.bind(port),
start: port.start.bind(port),
close: port.close.bind(port),
} as unknown as MessagePortMain
}
+122
View File
@@ -0,0 +1,122 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { bindIpcEvents } from "./ipc-events"
type PortBinding = {
readonly id: number
readonly sender: WebContents
readonly port: MessagePortMain
readonly parser: RpcSerialization.Parser
readonly onMessage: (event: Electron.MessageEvent) => void
readonly onClose: () => void
readonly unbindEvents: () => void
}
type Handoff = {
readonly bind: (sender: WebContents, port: MessagePortMain) => void
readonly sender: (clientId: number) => WebContents | undefined
}
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
export const IpcServerProtocolLive = Layer.unwrap(
Effect.gen(function* () {
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
const bindings = new Map<number, PortBinding>()
const senderBindings = new Map<number, number>()
const protocol = Layer.effect(
RpcServer.Protocol,
RpcServer.Protocol.make(
Effect.fnUntraced(function* (writeRequest) {
const serialization = yield* RpcSerialization.RpcSerialization
const disconnects = yield* Queue.unbounded<number>()
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
let nextClientId = 0
const disconnect = (id: number) => {
const binding = bindings.get(id)
if (!binding) return
bindings.delete(id)
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
binding.port.off("message", binding.onMessage)
binding.port.off("close", binding.onClose)
binding.sender.off("destroyed", binding.onClose)
binding.unbindEvents()
binding.port.close()
Queue.offerUnsafe(disconnects, id)
}
const bind = (sender: WebContents, port: MessagePortMain) => {
const previous = senderBindings.get(sender.id)
if (previous !== undefined) disconnect(previous)
if (sender.isDestroyed()) {
port.close()
return
}
const id = nextClientId++
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) =>
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
)
} catch {
return
}
}
const onClose = () => disconnect(id)
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents: bindIpcEvents(sender.id) }
bindings.set(id, binding)
senderBindings.set(sender.id, id)
port.on("message", onMessage)
port.on("close", onClose)
sender.once("destroyed", onClose)
port.start()
}
yield* Stream.fromQueue(handoffs).pipe(
Stream.runForEach(([sender, port]) => Effect.sync(() => bind(sender, port))),
Effect.forkScoped,
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach(([id, message]) => (bindings.has(id) ? writeRequest(id, message) : Effect.void)),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() => Effect.sync(() => [...bindings.keys()].forEach(disconnect)))
return {
disconnects,
send: (clientId, response) =>
Effect.sync(() => {
const binding = bindings.get(clientId)
if (!binding) return
const encoded = binding.parser.encode(response)
if (encoded !== undefined) binding.port.postMessage(encoded)
}),
end: (clientId) => Effect.sync(() => disconnect(clientId)),
clientIds: Effect.sync(() => new Set(bindings.keys())),
initialMessage: Effect.succeed(Option.none()),
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
}
}),
),
)
return Layer.merge(
protocol,
Layer.succeed(IpcPortHandoff)({
bind: (sender, port) => {
Queue.offerUnsafe(handoffs, [sender, port])
},
sender: (clientId) => bindings.get(clientId)?.sender,
}),
)
}),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
+44 -173
View File
@@ -1,184 +1,55 @@
import { BrowserWindow, ipcMain } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import {
Ipc,
type FatalRendererError,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
type ServerReadyData,
} from "../shared/ipc-contract"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Layer, ManagedRuntime } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { IpcTransportPort } from "../shared/ipc-transport"
import { createFileCapabilities } from "./files"
import { appHandlers, type AppHandlerDeps } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
import { fileHandlers } from "./ipc-handlers/files"
import { menuHandlers } from "./ipc-handlers/menu"
import { storageHandlers } from "./ipc-handlers/storage"
import { updaterHandlers } from "./ipc-handlers/updater"
import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { createDesktopStorage } from "./storage"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
type MaybePromise<Value> = Value | Promise<Value>
function handle<Channel extends keyof IpcInvoke>(
channel: Channel,
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
) {
ipcMain.handle(channel, listener)
}
function on<Channel extends keyof IpcSend>(
channel: Channel,
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
) {
ipcMain.on(channel, listener)
}
type Deps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
type Deps = AppHandlerDeps & {
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function registerIpcHandlers(deps: Deps) {
const files = createFileCapabilities()
const storage = createDesktopStorage()
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
export async function registerIpcHandlers(deps: Deps, updater: UpdaterIpc, wsl: WslIpc) {
const handlers = Layer.mergeAll(
appHandlers(deps),
storageHandlers(createDesktopStorage()),
fileHandlers(createFileCapabilities()),
windowHandlers,
menuHandlers(deps),
updaterHandlers(updater),
wslHandlers(wsl),
eventHandlers,
)
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
handle(Ipc.app.setNativeTranslations, (event, value) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
handle(Ipc.storage.get, (_event, name, key) => {
return storage.get(name, key)
})
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
handle(Ipc.storage.length, (_event, name) => storage.length(name))
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
handle(Ipc.window.getId, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
})
handle(Ipc.window.getFocused, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
handle(Ipc.window.getFullscreen, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
handle(Ipc.window.setFocus, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
handle(Ipc.window.show, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
on(Ipc.app.relaunch, () => {
deps.relaunch()
})
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
handle(Ipc.window.setZoomFactor, (event, factor) => {
event.sender.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
updateTitlebar(win)
})
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
setPinchZoomEnabled(enabled)
})
handle(Ipc.window.setTitlebar, (event, theme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
handle(Ipc.menu.runAction, (event, action) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
const live = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
Layer.provide(handlers),
Layer.provideMerge(IpcServerProtocolLive),
)
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const wire = (_event: Electron.Event, win: BrowserWindow) => {
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
app.once("will-quit", () => {
app.off("browser-window-created", wire)
void runtime.dispose()
})
}
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
handle(Ipc.updater.check, () => updater.check())
handle(Ipc.updater.install, () => updater.install())
}
export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
handle(Ipc.wsl.getState, () => wsl.getState())
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow } from "electron"
import type { Event } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { writeLog, type DesktopLogger } from "../native/logging"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
@@ -12,7 +13,7 @@ export function createApplicationLifecycle(logger: DesktopLogger) {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
}
const relaunch = () => {
setAppQuitting()
+3 -2
View File
@@ -6,7 +6,8 @@ import {
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { MenuCommandTriggered } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { UPDATER_ENABLED } from "../constants"
import { openExternalURL } from "../files"
@@ -36,7 +37,7 @@ export function createMenu(deps: Deps) {
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
sendIpcEvent(win.webContents, Ipc.menu.command, id)
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
+3 -2
View File
@@ -1,6 +1,7 @@
import { app, dialog } from "electron"
import type { WebContents } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { UpdaterStateChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { UPDATER_ENABLED } from "../constants"
import { getLogger } from "../native/logging"
import { nativeT } from "../native/translations"
@@ -53,7 +54,7 @@ export function createUpdaterIpc(controller: UpdaterController) {
id,
controller.subscribe((state) => {
if (sender.isDestroyed()) return unsubscribe(id)
sendIpcEvent(sender, Ipc.updater.state, state)
emitIpcEvent(sender, new UpdaterStateChanged({ state }))
}),
)
sender.once("destroyed", () => unsubscribe(id))
@@ -3,7 +3,9 @@ import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { join } from "node:path"
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
import { type TitlebarTheme } from "../../shared/ipc-contract"
import { WindowFullscreenChanged, WindowPinchZoomChanged, WindowZoomChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { developmentResourcesRoot, preloadPath } from "../paths"
import { PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
@@ -82,7 +84,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
BrowserWindow.getAllWindows().forEach((win) => {
pinchZoomEnabled.set(win, enabled)
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
emitIpcEvent(win.webContents, new WindowPinchZoomChanged({ enabled }))
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
@@ -111,7 +113,7 @@ export function wireZoom(win: BrowserWindow) {
export function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
emitIpcEvent(win.webContents, new WindowFullscreenChanged({ fullscreen }))
}
win.on("enter-full-screen", () => send(true))
win.on("leave-full-screen", () => send(false))
@@ -144,5 +146,5 @@ function clampZoom(value: number) {
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
emitIpcEvent(win.webContents, new WindowZoomChanged({ factor: win.webContents.getZoomFactor() }))
}
+32 -3
View File
@@ -1,7 +1,8 @@
import { app } from "electron"
import type { WebContents } from "electron"
import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { WslServersChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import type { WslServersController } from "./servers"
import { nativeT } from "../native/translations"
@@ -21,6 +22,34 @@ export type WslIpc = {
startServer(value: string): Promise<void>
}
export function createDeferredWslIpc() {
let current: WslIpc | undefined
const get = () => {
if (!current) throw new Error("WSL service is not initialized")
return current
}
return {
ipc: {
subscribe: (sender) => get().subscribe(sender),
unsubscribe: (id) => get().unsubscribe(id),
getState: () => get().getState(),
probeRuntime: () => get().probeRuntime(),
refreshDistros: () => get().refreshDistros(),
installWsl: () => get().installWsl(),
installDistro: (value) => get().installDistro(value),
probeAddable: (value) => get().probeAddable(value),
installOpencode: (value) => get().installOpencode(value),
openTerminal: (value) => get().openTerminal(value),
addServer: (value) => get().addServer(value),
removeServer: (value) => get().removeServer(value),
startServer: (value) => get().startServer(value),
} satisfies WslIpc,
set: (ipc: WslIpc) => {
current = ipc
},
}
}
export function createWslIpc(controller?: WslServersController): WslIpc {
if (!controller) return createUnavailableWslIpc()
@@ -48,7 +77,7 @@ export function createWslIpc(controller?: WslServersController): WslIpc {
unsubscribe(id)
return
}
sendIpcEvent(sender, Ipc.wsl.event, payload)
emitIpcEvent(sender, new WslServersChanged({ event: payload }))
}),
)
sender.once("destroyed", () => unsubscribe(id))
@@ -88,7 +117,7 @@ function createUnavailableWslIpc(): WslIpc {
})
return {
subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.event, { type: "state", state: state() }),
subscribe: (sender) => emitIpcEvent(sender, new WslServersChanged({ event: { type: "state", state: state() } })),
unsubscribe: () => undefined,
getState: state,
probeRuntime: unavailable,
+8 -135
View File
@@ -1,138 +1,11 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { IpcRendererEvent } from "electron"
import type { ElectronAPI } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import {
Ipc,
type IpcEvent,
type IpcEventListener,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
} from "../shared/ipc-contract"
import { IpcTransportPort } from "../shared/ipc-transport"
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
}
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
if (port) window.postMessage(IpcTransportPort, "*", [port])
})
function send<Channel extends keyof IpcSend>(channel: Channel, ...args: IpcSend[Channel]) {
ipcRenderer.send(channel, ...args)
}
function listen<Channel extends keyof IpcEvent>(channel: Channel, listener: IpcEventListener<Channel>) {
const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args)
ipcRenderer.on(channel, handler)
return () => ipcRenderer.removeListener(channel, handler)
}
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
const api: ElectronAPI = {
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
wslServers: {
getState: () => invoke(Ipc.wsl.getState),
subscribe: (cb) => {
const dispose = listen(Ipc.wsl.event, cb)
void invoke(Ipc.wsl.subscribe)
return () => {
dispose()
void invoke(Ipc.wsl.unsubscribe)
}
},
probeRuntime: () => invoke(Ipc.wsl.probeRuntime),
refreshDistros: () => invoke(Ipc.wsl.refreshDistros),
installWsl: () => invoke(Ipc.wsl.installWsl),
installDistro: (name) => invoke(Ipc.wsl.installDistro, name),
probeAddable: (distros) => invoke(Ipc.wsl.probeAddable, distros),
installOpencode: (name) => invoke(Ipc.wsl.installOpencode, name),
openTerminal: (name) => invoke(Ipc.wsl.openTerminal, name),
addServer: (distro) => invoke(Ipc.wsl.addServer, distro),
removeServer: (id) => invoke(Ipc.wsl.removeServer, id),
startServer: (id) => invoke(Ipc.wsl.startServer, id),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen(Ipc.updater.state, updaterHandler)
updaterSubscription = invoke(Ipc.updater.subscribe)
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke(Ipc.updater.unsubscribe)
}
},
check: () => invoke(Ipc.updater.check),
install: () => invoke(Ipc.updater.install),
},
consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks),
getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl),
setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url),
isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject),
checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName),
resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName),
storeGet: (name, key) => invoke(Ipc.storage.get, name, key),
storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value),
storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key),
storeClear: (name) => invoke(Ipc.storage.clear, name),
storeKeys: (name) => invoke(Ipc.storage.keys, name),
storeLength: (name) => invoke(Ipc.storage.length, name),
draftGet: (key) => invoke(Ipc.drafts.get, key),
draftSet: (key, value) => invoke(Ipc.drafts.set, key, value),
draftDelete: (key) => invoke(Ipc.drafts.delete, key),
draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data),
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
getWindowID: () => invoke(Ipc.window.getId),
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts),
openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts),
readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path),
releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts),
openExternal: (url) => send(Ipc.files.openExternal, url),
openLocalFile: (url) => send(Ipc.files.openLocalFile, url),
openPath: (path, app) => invoke(Ipc.files.openPath, path, app),
revealPath: (path) => invoke(Ipc.files.revealPath, path),
readClipboardImage: () => invoke(Ipc.files.readClipboardImage),
getWindowFocused: () => invoke(Ipc.window.getFocused),
getWindowFullscreen: () => invoke(Ipc.window.getFullscreen),
onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb),
setWindowFocus: () => invoke(Ipc.window.setFocus),
showWindow: () => invoke(Ipc.window.show),
relaunch: () => send(Ipc.app.relaunch),
getZoomFactor: () => invoke(Ipc.window.getZoomFactor),
setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor),
getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled),
setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled),
onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb),
onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb),
setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme),
runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action),
setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color),
exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs),
setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled),
recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error),
setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle),
}
contextBridge.exposeInMainWorld("api", api)
contextBridge.exposeInMainWorld("electron", {
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+2 -73
View File
@@ -1,74 +1,3 @@
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import {
Ipc,
type IpcEventListener,
type IpcEventSubscription,
type IpcInvokeMethod,
type IpcSendMethod,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: IpcEventListener<typeof Ipc.updater.state>) => Promise<() => void>
check: IpcInvokeMethod<typeof Ipc.updater.check>
install: IpcInvokeMethod<typeof Ipc.updater.install>
}
export type ElectronAPI = {
awaitInitialization: IpcInvokeMethod<typeof Ipc.app.awaitInitialization>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks: IpcInvokeMethod<typeof Ipc.app.consumeInitialDeepLinks>
getDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.getDefaultServerUrl>
setDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.setDefaultServerUrl>
isFirstLaunchOnboardingPending: IpcInvokeMethod<typeof Ipc.app.isFirstLaunchOnboardingPending>
finishFirstLaunchOnboarding: IpcInvokeMethod<typeof Ipc.app.finishFirstLaunchOnboarding>
checkAppExists: IpcInvokeMethod<typeof Ipc.app.checkAppExists>
resolveAppPath: IpcInvokeMethod<typeof Ipc.app.resolveAppPath>
storeGet: IpcInvokeMethod<typeof Ipc.storage.get>
storeSet: IpcInvokeMethod<typeof Ipc.storage.set>
storeDelete: IpcInvokeMethod<typeof Ipc.storage.delete>
storeClear: IpcInvokeMethod<typeof Ipc.storage.clear>
storeKeys: IpcInvokeMethod<typeof Ipc.storage.keys>
storeLength: IpcInvokeMethod<typeof Ipc.storage.length>
draftGet: IpcInvokeMethod<typeof Ipc.drafts.get>
draftSet: IpcInvokeMethod<typeof Ipc.drafts.set>
draftDelete: IpcInvokeMethod<typeof Ipc.drafts.delete>
draftBlobPut: IpcInvokeMethod<typeof Ipc.drafts.putBlob>
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
openDirectoryPicker: IpcInvokeMethod<typeof Ipc.files.openDirectoryPicker>
openFilePicker: IpcInvokeMethod<typeof Ipc.files.openFilePicker>
readPickedFile: IpcInvokeMethod<typeof Ipc.files.readPickedFile>
releasePickedFiles: IpcInvokeMethod<typeof Ipc.files.releasePickedFiles>
getPathForFile: (file: File) => string
saveFilePicker: IpcInvokeMethod<typeof Ipc.files.saveFilePicker>
openExternal: IpcSendMethod<typeof Ipc.files.openExternal>
openLocalFile: IpcSendMethod<typeof Ipc.files.openLocalFile>
openPath: IpcInvokeMethod<typeof Ipc.files.openPath>
revealPath: IpcInvokeMethod<typeof Ipc.files.revealPath>
readClipboardImage: IpcInvokeMethod<typeof Ipc.files.readClipboardImage>
getWindowFocused: IpcInvokeMethod<typeof Ipc.window.getFocused>
getWindowFullscreen: IpcInvokeMethod<typeof Ipc.window.getFullscreen>
onWindowFullscreenChanged: IpcEventSubscription<typeof Ipc.window.fullscreenChanged>
setWindowFocus: IpcInvokeMethod<typeof Ipc.window.setFocus>
showWindow: IpcInvokeMethod<typeof Ipc.window.show>
relaunch: IpcSendMethod<typeof Ipc.app.relaunch>
getZoomFactor: IpcInvokeMethod<typeof Ipc.window.getZoomFactor>
setZoomFactor: IpcInvokeMethod<typeof Ipc.window.setZoomFactor>
getPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.getPinchZoomEnabled>
setPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.setPinchZoomEnabled>
onPinchZoomEnabledChanged: IpcEventSubscription<typeof Ipc.window.pinchZoomEnabledChanged>
onZoomFactorChanged: IpcEventSubscription<typeof Ipc.window.zoomFactorChanged>
setTitlebar: IpcInvokeMethod<typeof Ipc.window.setTitlebar>
runDesktopMenuAction: IpcInvokeMethod<typeof Ipc.menu.runAction>
setBackgroundColor: IpcInvokeMethod<typeof Ipc.app.setBackgroundColor>
exportDebugLogs: IpcInvokeMethod<typeof Ipc.app.exportDebugLogs>
setForceFocus: IpcInvokeMethod<typeof Ipc.app.setForceFocus>
recordFatalRendererError: IpcInvokeMethod<typeof Ipc.app.recordFatalRendererError>
setNativeTranslations: IpcInvokeMethod<typeof Ipc.app.setNativeTranslations>
export type ElectronNative = {
getPathForFile(file: File): string
}
@@ -0,0 +1,78 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type {
ClipboardImage,
DirectoryPickerOptions,
FatalRendererError,
FilePickerOptions,
PickedFiles,
SaveFilePickerOptions,
ServerReadyData,
TitlebarTheme,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe(cb: (state: UpdaterState) => void): Promise<() => void>
check(): Promise<UpdaterState>
install(): Promise<void>
}
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
getDefaultServerUrl(): Promise<string | null>
setDefaultServerUrl(url: string | null): Promise<void>
isFirstLaunchOnboardingPending(): Promise<boolean>
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
resolveAppPath(appName: string): Promise<string | null>
storeGet(name: string, key: string): Promise<string | null>
storeSet(name: string, key: string, value: string): Promise<void>
storeDelete(name: string, key: string): Promise<void>
storeClear(name: string): Promise<void>
storeKeys(name: string): Promise<string[]>
storeLength(name: string): Promise<number>
draftGet(key: string): Promise<string | null>
draftSet(key: string, value: string): Promise<void>
draftDelete(key: string): Promise<void>
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): Promise<string>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
readPickedFile(token: string, path: string): Promise<ArrayBuffer>
releasePickedFiles(token: string): Promise<void>
getPathForFile(file: File): string
saveFilePicker(opts?: SaveFilePickerOptions): Promise<string | null>
openExternal(url: string): void
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
readClipboardImage(): Promise<ClipboardImage | null>
getWindowFocused(): Promise<boolean>
getWindowFullscreen(): Promise<boolean>
onWindowFullscreenChanged(cb: (fullscreen: boolean) => void): () => void
setWindowFocus(): Promise<void>
showWindow(): Promise<void>
relaunch(): void
getZoomFactor(): Promise<number>
setZoomFactor(factor: number): Promise<void>
getPinchZoomEnabled(): Promise<boolean>
setPinchZoomEnabled(enabled: boolean): Promise<void>
onPinchZoomEnabledChanged(cb: (enabled: boolean) => void): () => void
onZoomFactorChanged(cb: (factor: number) => void): () => void
setTitlebar(theme: TitlebarTheme): Promise<void>
runDesktopMenuAction(action: DesktopMenuAction): Promise<void>
setBackgroundColor(color: string): Promise<void>
exportDebugLogs(): Promise<string>
setForceFocus(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
}
+126
View File
@@ -0,0 +1,126 @@
import type { ElectronAPI } from "./api-types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { invoke, listen, send } from "./ipc-client"
type Mutable<Value> =
Value extends ReadonlyArray<unknown>
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value extends object
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value
const mutable = <Value>(value: Value) => value as Mutable<Value>
const toArrayBuffer = (value: Uint8Array) =>
value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
const dispose = listen("WslServersChanged", (event) => cb(mutable(event.event)))
void invoke("WslSubscribe")
return () => {
dispose()
void invoke("WslUnsubscribe")
}
},
probeRuntime: () => invoke("WslProbeRuntime"),
refreshDistros: () => invoke("WslRefreshDistros"),
installWsl: () => invoke("WslInstallWsl"),
installDistro: (name) => invoke("WslInstallDistro", { name }),
probeAddable: (distros) => invoke("WslProbeAddable", { distros }),
installOpencode: (name) => invoke("WslInstallOpencode", { name }),
openTerminal: (name) => invoke("WslOpenTerminal", { name }),
addServer: (distro) => invoke("WslAddServer", { distro }),
removeServer: (id) => invoke("WslRemoveServer", { id }),
startServer: (id) => invoke("WslStartServer", { id }),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen("UpdaterStateChanged", (event) => updaterHandler(mutable(event.state)))
updaterSubscription = invoke("UpdaterSubscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke("UpdaterUnsubscribe")
}
},
check: () => invoke("UpdaterCheck"),
install: () => invoke("UpdaterInstall"),
},
consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable),
getDefaultServerUrl: () => invoke("AppGetDefaultServerUrl"),
setDefaultServerUrl: (url) => invoke("AppSetDefaultServerUrl", { url }),
isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeGet: (name, key) => invoke("StorageGet", { name, key }),
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
storeClear: (name) => invoke("StorageClear", { name }),
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
storeLength: (name) => invoke("StorageLength", { name }),
draftGet: (key) => invoke("DraftsGet", { key }),
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
draftDelete: (key) => invoke("DraftsDelete", { key }),
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => invoke("WindowGetId"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable),
openFilePicker: (opts) => invoke("FilesOpenFilePicker", { options: opts }).then(mutable),
readPickedFile: (token, path) => invoke("FilesReadPickedFile", { token, path }).then(toArrayBuffer),
releasePickedFiles: (token) => invoke("FilesReleasePickedFiles", { token }),
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFilePicker: (opts) => invoke("FilesSaveFilePicker", { options: opts }),
openExternal: (url) => send("FilesOpenExternal", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
readClipboardImage: () =>
invoke("FilesReadClipboardImage").then((image) =>
image ? { ...image, buffer: toArrayBuffer(image.buffer) } : null,
),
getWindowFocused: () => invoke("WindowGetFocused"),
getWindowFullscreen: () => invoke("WindowGetFullscreen"),
onWindowFullscreenChanged: (cb) => listen("WindowFullscreenChanged", (event) => cb(event.fullscreen)),
setWindowFocus: () => invoke("WindowSetFocus"),
showWindow: () => invoke("WindowShow"),
relaunch: () => send("AppRelaunch"),
getZoomFactor: () => invoke("WindowGetZoomFactor"),
setZoomFactor: (factor) => invoke("WindowSetZoomFactor", { factor }),
getPinchZoomEnabled: () => invoke("WindowGetPinchZoomEnabled"),
setPinchZoomEnabled: (enabled) => invoke("WindowSetPinchZoomEnabled", { enabled }),
onPinchZoomEnabledChanged: (cb) => listen("WindowPinchZoomChanged", (event) => cb(event.enabled)),
onZoomFactorChanged: (cb) => listen("WindowZoomChanged", (event) => cb(event.factor)),
setTitlebar: (theme) => invoke("WindowSetTitlebar", { theme }),
runDesktopMenuAction: (action) => invoke("MenuRunAction", { action }),
setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }),
exportDebugLogs: () => invoke("AppExportDebugLogs"),
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
}
@@ -14,7 +14,7 @@ import {
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import type { ElectronAPI } from "../preload/types"
import type { ElectronAPI } from "./api-types"
import { MigrationStatus } from "./migration-status"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
+2 -2
View File
@@ -1,8 +1,8 @@
import type { ElectronAPI } from "../preload/types"
import type { ElectronNative } from "../preload/types"
declare global {
interface Window {
api: ElectronAPI
electron: ElectronNative
__OPENCODE__?: {
deepLinks?: string[]
}
+2 -1
View File
@@ -1,4 +1,5 @@
import * as i18n from "@solid-primitives/i18n"
import { api } from "../api"
import {
DESKTOP_NATIVE_LOCALES,
detectDesktopNativeLocale,
@@ -199,7 +200,7 @@ export function initI18n(): Promise<Locale> {
if (cached) return cached
const promise = (async () => {
const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
const raw = await api.storeGet("opencode.global.dat", "language").catch(() => null)
const value = parseStored(raw)
const next = pickLocale(value) ?? state.locale
+5 -4
View File
@@ -3,6 +3,7 @@
import "./diagnostics"
import "./styles.css"
import { render } from "solid-js/web"
import { api } from "./api"
import { DesktopApp } from "./desktop-app"
import { startDesktopMenu } from "./platform/menu"
import { startDesktopUpdater } from "./platform/updater"
@@ -14,8 +15,8 @@ const root = requireRendererRoot()
const version = desktopVersion()
initializeSentry(version)
const updater = startDesktopUpdater(window.api)
startDesktopMenu(window.api)
startDeepLinks(window.api)
const updater = startDesktopUpdater(api)
startDesktopMenu(api)
startDeepLinks(api)
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
+113
View File
@@ -0,0 +1,113 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
resolve(value)
}
window.addEventListener("message", onMessage)
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
void invoke(tag, ...payload).catch(() => undefined)
}
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
const callback = listener as (value: unknown) => void
const callbacks = listeners.get(tag) ?? new Set()
callbacks.add(callback)
listeners.set(tag, callbacks)
return () => {
callbacks.delete(callback)
if (callbacks.size === 0) listeners.delete(tag)
}
}
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
} catch {
return
}
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId, request) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) value.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
@@ -1,6 +1,6 @@
import { ServerConnection, useServers, useTabs } from "@opencode-ai/app"
import { onMount } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
type DesktopOS = Extract<Platform, { platform: "desktop" }>["os"]
type DesktopFileAPI = Pick<
@@ -1,5 +1,5 @@
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
import { createDesktopFiles } from "./files"
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
let trigger: ((id: string) => void) | null = null
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
return async (title, description, onClick) => {
@@ -1,6 +1,6 @@
import { createDraftStore, type Platform } from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopStorage(api: ElectronAPI) {
const cache = new Map<string, AsyncStorage>()
@@ -1,6 +1,6 @@
import type { UpdaterPlatform, UpdaterState } from "@opencode-ai/app"
import { createSignal } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
const [state, setState] = createSignal<UpdaterState>({ status: "disabled" })
@@ -1,4 +1,4 @@
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
const deepLinkEvent = "opencode:deep-link"
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { Ipc } from "../../shared/ipc-contract"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
@@ -15,10 +14,8 @@ describe("desktop renderer initialization", () => {
}
})
test("removes Electron's remote invocation wrapper from startup errors", () => {
const error = new Error(
`Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`,
)
test("preserves clean RPC startup errors", () => {
const error = new Error("Cannot migrate session_message projections")
try {
initializationData(Object.assign(() => undefined, { error }))
@@ -5,12 +5,6 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: `
if (failure.message.startsWith(prefix)) {
const previous = failure.message
failure.message = failure.message.slice(prefix.length)
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
}
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
@@ -20,4 +14,3 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
initializationData(state)
return true
}
import { Ipc } from "../../shared/ipc-contract"
@@ -1,8 +1,9 @@
import { createSignal } from "solid-js"
import { api } from "../api"
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
window.api.onWindowFullscreenChanged(setWindowFullscreen)
void window.api.getWindowFullscreen().then(setWindowFullscreen)
api.onWindowFullscreenChanged(setWindowFullscreen)
void api.getWindowFullscreen().then(setWindowFullscreen)
export { windowFullscreen }
+6 -5
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import { createSignal } from "solid-js"
import { api } from "../api"
const OS_NAME = (() => {
if (navigator.userAgent.includes("Mac")) return "macos"
@@ -33,7 +34,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
const applyZoom = (next: number) => {
requestedZoom = next
void window.api
void api
.setZoomFactor(next)
.then(() => {
if (requestedZoom !== next) return
@@ -45,16 +46,16 @@ const applyZoom = (next: number) => {
})
}
window.api.onZoomFactorChanged((factor) => {
api.onZoomFactorChanged((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
void window.api.getPinchZoomEnabled().then((enabled) => {
void api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
})
window.api.onPinchZoomEnabledChanged((enabled) => {
api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
})
@@ -62,7 +63,7 @@ window.api.onPinchZoomEnabledChanged((enabled) => {
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
return window.api.setPinchZoomEnabled(enabled)
return api.setPinchZoomEnabled(enabled)
}
const resetZoom = () => applyZoom(1)
-203
View File
@@ -1,97 +1,3 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types"
export const Ipc = {
app: {
awaitInitialization: "await-initialization",
consumeInitialDeepLinks: "consume-initial-deep-links",
deepLink: "deep-link",
getDefaultServerUrl: "get-default-server-url",
setDefaultServerUrl: "set-default-server-url",
isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending",
finishFirstLaunchOnboarding: "finish-first-launch-onboarding",
checkAppExists: "check-app-exists",
resolveAppPath: "resolve-app-path",
relaunch: "relaunch",
setBackgroundColor: "set-background-color",
exportDebugLogs: "export-debug-logs",
setForceFocus: "set-force-focus",
recordFatalRendererError: "record-fatal-renderer-error",
setNativeTranslations: "set-native-translations",
},
storage: {
get: "store-get",
set: "store-set",
delete: "store-delete",
clear: "store-clear",
keys: "store-keys",
length: "store-length",
},
drafts: {
get: "draft-get",
set: "draft-set",
delete: "draft-delete",
putBlob: "draft-blob-put",
getBlob: "draft-blob-get",
},
files: {
openDirectoryPicker: "open-directory-picker",
openFilePicker: "open-file-picker",
readPickedFile: "read-picked-file",
releasePickedFiles: "release-picked-files",
saveFilePicker: "save-file-picker",
openExternal: "open-external",
openLocalFile: "open-local-file",
openPath: "open-path",
revealPath: "reveal-path",
readClipboardImage: "read-clipboard-image",
},
window: {
getId: "get-window-id",
getFocused: "get-window-focused",
getFullscreen: "get-window-fullscreen",
fullscreenChanged: "window-fullscreen-changed",
setFocus: "set-window-focus",
show: "show-window",
getZoomFactor: "get-zoom-factor",
setZoomFactor: "set-zoom-factor",
zoomFactorChanged: "zoom-factor-changed",
getPinchZoomEnabled: "get-pinch-zoom-enabled",
setPinchZoomEnabled: "set-pinch-zoom-enabled",
pinchZoomEnabledChanged: "pinch-zoom-enabled-changed",
setTitlebar: "set-titlebar",
},
menu: {
command: "menu-command",
runAction: "run-desktop-menu-action",
},
updater: {
subscribe: "updater-subscribe",
unsubscribe: "updater-unsubscribe",
check: "updater-check",
install: "updater-install",
state: "updater-state",
},
wsl: {
subscribe: "wsl-servers-subscribe",
unsubscribe: "wsl-servers-unsubscribe",
getState: "wsl-servers-get-state",
probeRuntime: "wsl-servers-probe-runtime",
refreshDistros: "wsl-servers-refresh-distros",
installWsl: "wsl-servers-install-wsl",
installDistro: "wsl-servers-install-distro",
probeAddable: "wsl-servers-probe-addable",
installOpencode: "wsl-servers-install-opencode",
openTerminal: "wsl-servers-open-terminal",
addServer: "wsl-servers-add",
removeServer: "wsl-servers-remove",
startServer: "wsl-servers-start",
event: "wsl-servers-event",
},
} as const
export type ServerReadyData = {
url: string
username: string | null
@@ -136,112 +42,3 @@ export type ClipboardImage = {
width: number
height: number
}
export type IpcInvoke = {
[Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData }
[Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] }
[Ipc.app.getDefaultServerUrl]: { args: []; result: string | null }
[Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void }
[Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean }
[Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null }
[Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean }
[Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null }
[Ipc.app.setBackgroundColor]: { args: [color: string]; result: void }
[Ipc.app.exportDebugLogs]: { args: []; result: string }
[Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void }
[Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void }
[Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void }
[Ipc.storage.get]: { args: [name: string, key: string]; result: string | null }
[Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void }
[Ipc.storage.delete]: { args: [name: string, key: string]; result: void }
[Ipc.storage.clear]: { args: [name: string]; result: void }
[Ipc.storage.keys]: { args: [name: string]; result: string[] }
[Ipc.storage.length]: { args: [name: string]; result: number }
[Ipc.drafts.get]: { args: [key: string]; result: string | null }
[Ipc.drafts.set]: { args: [key: string, value: string]; result: void }
[Ipc.drafts.delete]: { args: [key: string]; result: void }
[Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string }
[Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null }
[Ipc.files.openDirectoryPicker]: {
args: [options?: DirectoryPickerOptions]
result: string | string[] | null
}
[Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null }
[Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer }
[Ipc.files.releasePickedFiles]: { args: [token: string]; result: void }
[Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null }
[Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined }
[Ipc.files.revealPath]: { args: [path: string]; result: boolean }
[Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null }
[Ipc.window.getId]: { args: []; result: string }
[Ipc.window.getFocused]: { args: []; result: boolean }
[Ipc.window.getFullscreen]: { args: []; result: boolean }
[Ipc.window.setFocus]: { args: []; result: void }
[Ipc.window.show]: { args: []; result: void }
[Ipc.window.getZoomFactor]: { args: []; result: number }
[Ipc.window.setZoomFactor]: { args: [factor: number]; result: void }
[Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean }
[Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void }
[Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void }
[Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void }
[Ipc.updater.subscribe]: { args: []; result: void }
[Ipc.updater.unsubscribe]: { args: []; result: void }
[Ipc.updater.check]: { args: []; result: UpdaterState }
[Ipc.updater.install]: { args: []; result: void }
[Ipc.wsl.subscribe]: { args: []; result: void }
[Ipc.wsl.unsubscribe]: { args: []; result: void }
[Ipc.wsl.getState]: { args: []; result: WslServersState }
[Ipc.wsl.probeRuntime]: { args: []; result: void }
[Ipc.wsl.refreshDistros]: { args: []; result: void }
[Ipc.wsl.installWsl]: { args: []; result: void }
[Ipc.wsl.installDistro]: { args: [name: string]; result: void }
[Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void }
[Ipc.wsl.installOpencode]: { args: [name: string]; result: void }
[Ipc.wsl.openTerminal]: { args: [name: string]; result: void }
[Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig }
[Ipc.wsl.removeServer]: { args: [id: string]; result: void }
[Ipc.wsl.startServer]: { args: [id: string]; result: void }
}
export type IpcSend = {
[Ipc.app.relaunch]: []
[Ipc.files.openExternal]: [url: string]
[Ipc.files.openLocalFile]: [url: string]
}
export type IpcEvent = {
[Ipc.app.deepLink]: [urls: string[]]
[Ipc.menu.command]: [id: string]
[Ipc.updater.state]: [state: UpdaterState]
[Ipc.wsl.event]: [event: WslServersEvent]
[Ipc.window.fullscreenChanged]: [fullscreen: boolean]
[Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean]
[Ipc.window.zoomFactorChanged]: [factor: number]
}
export type IpcInvokeArgs<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["args"]
export type IpcInvokeResult<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["result"]
export type IpcInvokeMethod<Channel extends keyof IpcInvoke> = (
...args: IpcInvokeArgs<Channel>
) => Promise<IpcInvokeResult<Channel>>
export type IpcSendMethod<Channel extends keyof IpcSend> = (...args: IpcSend[Channel]) => void
export type IpcEventListener<Channel extends keyof IpcEvent> = (...args: IpcEvent[Channel]) => void
export type IpcEventSubscription<Channel extends keyof IpcEvent> = (listener: IpcEventListener<Channel>) => () => void
type IpcEventSender = {
send<Channel extends keyof IpcEvent>(channel: Channel, ...args: IpcEvent[Channel]): void
}
export function sendIpcEvent<Channel extends keyof IpcEvent>(
sender: IpcEventSender,
channel: Channel,
...args: IpcEvent[Channel]
) {
sender.send(channel, ...args)
}
+21
View File
@@ -0,0 +1,21 @@
import { RpcClient, RpcClientError } from "effect/unstable/rpc"
import { AppRpcs } from "./ipc-rpc/app"
import { EventRpcs } from "./ipc-rpc/events"
import { FileRpcs } from "./ipc-rpc/files"
import { MenuRpcs } from "./ipc-rpc/menu"
import { StorageRpcs } from "./ipc-rpc/storage"
import { UpdaterRpcs } from "./ipc-rpc/updater"
import { WindowRpcs } from "./ipc-rpc/window"
import { WslRpcs } from "./ipc-rpc/wsl"
export { AppRpcs } from "./ipc-rpc/app"
export { EventRpcs } from "./ipc-rpc/events"
export { FileRpcs } from "./ipc-rpc/files"
export { MenuRpcs } from "./ipc-rpc/menu"
export { StorageRpcs } from "./ipc-rpc/storage"
export { UpdaterRpcs } from "./ipc-rpc/updater"
export { WindowRpcs } from "./ipc-rpc/window"
export { WslRpcs } from "./ipc-rpc/wsl"
export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
export type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
@@ -0,0 +1,72 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServerReadyData = Schema.Struct({
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
success: Schema.Array(Schema.String),
})
export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", {
success: Schema.NullOr(Schema.String),
})
export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", {
payload: { url: Schema.NullOr(Schema.String) },
})
export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", {
success: Schema.Boolean,
})
export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", {
payload: { createDefaultProject: Schema.Boolean },
success: Schema.NullOr(Schema.String),
})
export const AppCheckAppExists = Rpc.make("AppCheckAppExists", {
payload: { appName: Schema.String },
success: Schema.Boolean,
})
export const AppResolveAppPath = Rpc.make("AppResolveAppPath", {
payload: { appName: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const AppSetBackgroundColor = Rpc.make("AppSetBackgroundColor", {
payload: { color: Schema.String },
})
export const AppExportDebugLogs = Rpc.make("AppExportDebugLogs", { success: Schema.String })
export const AppSetForceFocus = Rpc.make("AppSetForceFocus", {
payload: { enabled: Schema.Boolean },
})
export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError", {
payload: {
error: Schema.Struct({
error: Schema.String,
url: Schema.String,
version: Schema.optionalKey(Schema.String),
platform: Schema.String,
os: Schema.optionalKey(Schema.String),
}),
},
})
export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppConsumeInitialDeepLinks,
AppGetDefaultServerUrl,
AppSetDefaultServerUrl,
AppIsFirstLaunchOnboardingPending,
AppFinishFirstLaunchOnboarding,
AppCheckAppExists,
AppResolveAppPath,
AppSetBackgroundColor,
AppExportDebugLogs,
AppSetForceFocus,
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,
)
@@ -0,0 +1,46 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
urls: Schema.Array(Schema.String),
}) {}
export class MenuCommandTriggered extends Schema.TaggedClass<MenuCommandTriggered>()("MenuCommandTriggered", {
id: Schema.String,
}) {}
export class UpdaterStateChanged extends Schema.TaggedClass<UpdaterStateChanged>()("UpdaterStateChanged", {
state: UpdaterStateSchema,
}) {}
export class WslServersChanged extends Schema.TaggedClass<WslServersChanged>()("WslServersChanged", {
event: WslServersEventSchema,
}) {}
export class WindowFullscreenChanged extends Schema.TaggedClass<WindowFullscreenChanged>()("WindowFullscreenChanged", {
fullscreen: Schema.Boolean,
}) {}
export class WindowPinchZoomChanged extends Schema.TaggedClass<WindowPinchZoomChanged>()("WindowPinchZoomChanged", {
enabled: Schema.Boolean,
}) {}
export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("WindowZoomChanged", {
factor: Schema.Number,
}) {}
export const DesktopEvent = Schema.Union([
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
WslServersChanged,
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
])
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents)
@@ -0,0 +1,71 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const OptionalString = Schema.optionalKey(Schema.String)
const PickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
})
const FilePickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
extensions: Schema.optionalKey(Schema.Array(Schema.String)),
})
const SavePickerOptions = Schema.Struct({ title: OptionalString, defaultPath: OptionalString })
const PickedFiles = Schema.Struct({
token: Schema.String,
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
})
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
payload: { options: Schema.optionalKey(PickerOptions) },
success: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
})
export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
payload: { options: Schema.optionalKey(FilePickerOptions) },
success: Schema.NullOr(PickedFiles),
})
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
payload: { token: Schema.String, path: Schema.String },
success: Schema.Uint8Array,
})
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
payload: { token: Schema.String },
})
export const FilesSaveFilePicker = Rpc.make("FilesSaveFilePicker", {
payload: { options: Schema.optionalKey(SavePickerOptions) },
success: Schema.NullOr(Schema.String),
})
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
export const FilesOpenPath = Rpc.make("FilesOpenPath", {
payload: { path: Schema.String, application: Schema.optionalKey(Schema.String) },
success: Schema.NullOr(Schema.String),
})
export const FilesRevealPath = Rpc.make("FilesRevealPath", {
payload: { path: Schema.String },
success: Schema.Boolean,
})
export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", {
success: Schema.NullOr(ClipboardImage),
})
export const FileRpcs = RpcGroup.make(
FilesOpenDirectoryPicker,
FilesOpenFilePicker,
FilesReadPickedFile,
FilesReleasePickedFiles,
FilesSaveFilePicker,
FilesOpenExternal,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
FilesReadClipboardImage,
)
@@ -0,0 +1,29 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.relaunch",
"edit.undo",
"edit.redo",
"edit.cut",
"edit.copy",
"edit.paste",
"edit.delete",
"edit.selectAll",
"view.reload",
"view.toggleDevTools",
"view.resetZoom",
"view.zoomIn",
"view.zoomOut",
"view.toggleFullscreen",
"window.new",
"window.close",
"window.minimize",
"window.toggleMaximize",
])
export const MenuRunAction = Rpc.make("MenuRunAction", {
payload: { action: DesktopMenuAction },
})
export const MenuRpcs = RpcGroup.make(MenuRunAction)
@@ -0,0 +1,52 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const StorageGet = Rpc.make("StorageGet", {
payload: { name: Schema.String, key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const StorageSet = Rpc.make("StorageSet", {
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
})
export const StorageDelete = Rpc.make("StorageDelete", {
payload: { name: Schema.String, key: Schema.String },
})
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
export const StorageKeys = Rpc.make("StorageKeys", {
payload: { name: Schema.String },
success: Schema.Array(Schema.String),
})
export const StorageLength = Rpc.make("StorageLength", {
payload: { name: Schema.String },
success: Schema.Number,
})
export const DraftsGet = Rpc.make("DraftsGet", {
payload: { key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const DraftsSet = Rpc.make("DraftsSet", {
payload: { key: Schema.String, value: Schema.String },
})
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
payload: { data: Schema.Uint8Array },
success: Schema.String,
})
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
payload: { id: Schema.String },
success: Schema.NullOr(Schema.Uint8Array),
})
export const StorageRpcs = RpcGroup.make(
StorageGet,
StorageSet,
StorageDelete,
StorageClear,
StorageKeys,
StorageLength,
DraftsGet,
DraftsSet,
DraftsDelete,
DraftsPutBlob,
DraftsGetBlob,
)
@@ -0,0 +1,19 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const UpdaterStateSchema = Schema.Union([
Schema.Struct({ status: Schema.Literal("disabled") }),
Schema.Struct({ status: Schema.Literal("idle") }),
Schema.Struct({ status: Schema.Literal("checking") }),
Schema.Struct({ status: Schema.Literal("downloading"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("ready"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("up-to-date") }),
Schema.Struct({ status: Schema.Literal("installing"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("error"), message: Schema.String }),
])
export const UpdaterSubscribe = Rpc.make("UpdaterSubscribe")
export const UpdaterUnsubscribe = Rpc.make("UpdaterUnsubscribe")
export const UpdaterCheck = Rpc.make("UpdaterCheck", { success: UpdaterStateSchema })
export const UpdaterInstall = Rpc.make("UpdaterInstall")
export const UpdaterRpcs = RpcGroup.make(UpdaterSubscribe, UpdaterUnsubscribe, UpdaterCheck, UpdaterInstall)
@@ -0,0 +1,38 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
export const WindowSetFocus = Rpc.make("WindowSetFocus")
export const WindowShow = Rpc.make("WindowShow")
export const WindowGetZoomFactor = Rpc.make("WindowGetZoomFactor", { success: Schema.Number })
export const WindowSetZoomFactor = Rpc.make("WindowSetZoomFactor", {
payload: { factor: Schema.Number },
})
export const WindowGetPinchZoomEnabled = Rpc.make("WindowGetPinchZoomEnabled", {
success: Schema.Boolean,
})
export const WindowSetPinchZoomEnabled = Rpc.make("WindowSetPinchZoomEnabled", {
payload: { enabled: Schema.Boolean },
})
export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
payload: {
theme: Schema.Struct({
mode: Schema.Literals(["light", "dark"]),
scheme: Schema.optionalKey(Schema.Literals(["system", "light", "dark"])),
}),
},
})
export const WindowRpcs = RpcGroup.make(
WindowGetId,
WindowGetFocused,
WindowGetFullscreen,
WindowSetFocus,
WindowShow,
WindowGetZoomFactor,
WindowSetZoomFactor,
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
)
+109
View File
@@ -0,0 +1,109 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const WslServerConfig = Schema.Struct({ id: Schema.String, distro: Schema.String })
const WslServerRuntime = Schema.Union([
Schema.Struct({ kind: Schema.Literal("starting") }),
Schema.Struct({
kind: Schema.Literal("ready"),
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
}),
Schema.Struct({ kind: Schema.Literal("failed"), message: Schema.String }),
Schema.Struct({ kind: Schema.Literal("stopped") }),
])
const WslJob = Schema.Union([
Schema.Struct({ kind: Schema.Literal("runtime"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("distros"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-wsl"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-distro"), distro: Schema.String, startedAt: Schema.Number }),
Schema.Struct({
kind: Schema.Literal("probe-addable"),
distros: Schema.Array(Schema.String),
startedAt: Schema.Number,
}),
Schema.Struct({ kind: Schema.Literal("install-opencode"), distro: Schema.String, startedAt: Schema.Number }),
])
const WslServersState = Schema.Struct({
runtime: Schema.NullOr(
Schema.Struct({
available: Schema.Boolean,
version: Schema.NullOr(Schema.String),
error: Schema.NullOr(Schema.String),
}),
),
installed: Schema.Array(
Schema.Struct({ name: Schema.String, version: Schema.NullOr(Schema.Number), isDefault: Schema.Boolean }),
),
online: Schema.Array(Schema.Struct({ name: Schema.String, label: Schema.String })),
distroProbes: Schema.Record(
Schema.String,
Schema.Struct({
name: Schema.String,
canExecute: Schema.Boolean,
hasBash: Schema.Boolean,
hasCurl: Schema.Boolean,
error: Schema.NullOr(Schema.String),
}),
),
opencodeChecks: Schema.Record(
Schema.String,
Schema.Struct({
distro: Schema.String,
resolvedPath: Schema.NullOr(Schema.String),
version: Schema.NullOr(Schema.String),
expectedVersion: Schema.NullOr(Schema.String),
matchesDesktop: Schema.NullOr(Schema.Boolean),
error: Schema.NullOr(Schema.String),
}),
),
pendingRestart: Schema.Boolean,
servers: Schema.Array(Schema.Struct({ config: WslServerConfig, runtime: WslServerRuntime })),
job: Schema.NullOr(WslJob),
})
export const WslServersEventSchema = Schema.Struct({ type: Schema.Literal("state"), state: WslServersState })
export const WslSubscribe = Rpc.make("WslSubscribe")
export const WslUnsubscribe = Rpc.make("WslUnsubscribe")
export const WslGetState = Rpc.make("WslGetState", { success: WslServersState })
export const WslProbeRuntime = Rpc.make("WslProbeRuntime")
export const WslRefreshDistros = Rpc.make("WslRefreshDistros")
export const WslInstallWsl = Rpc.make("WslInstallWsl")
export const WslInstallDistro = Rpc.make("WslInstallDistro", {
payload: { name: Schema.String },
})
export const WslProbeAddable = Rpc.make("WslProbeAddable", {
payload: { distros: Schema.Array(Schema.String) },
})
export const WslInstallOpencode = Rpc.make("WslInstallOpencode", {
payload: { name: Schema.String },
})
export const WslOpenTerminal = Rpc.make("WslOpenTerminal", {
payload: { name: Schema.String },
})
export const WslAddServer = Rpc.make("WslAddServer", {
payload: { distro: Schema.String },
success: WslServerConfig,
})
export const WslRemoveServer = Rpc.make("WslRemoveServer", {
payload: { id: Schema.String },
})
export const WslStartServer = Rpc.make("WslStartServer", {
payload: { id: Schema.String },
})
export const WslRpcs = RpcGroup.make(
WslSubscribe,
WslUnsubscribe,
WslGetState,
WslProbeRuntime,
WslRefreshDistros,
WslInstallWsl,
WslInstallDistro,
WslProbeAddable,
WslInstallOpencode,
WslOpenTerminal,
WslAddServer,
WslRemoveServer,
WslStartServer,
)
@@ -0,0 +1 @@
export const IpcTransportPort = "desktop-rpc-port"