mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d3824212 |
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user