Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { execFile } from "node:child_process"
|
|
import { stat } from "node:fs/promises"
|
|
import { basename } from "node:path"
|
|
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
|
|
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
|
|
|
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
|
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
|
import { getStore, removeStoreFileIfEmpty } from "./store"
|
|
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
|
import type { UpdaterController } from "./updater-controller"
|
|
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
|
|
|
const pickerFilters = (ext?: string[]) => {
|
|
if (!ext || ext.length === 0) return undefined
|
|
return [{ name: "Files", extensions: ext }]
|
|
}
|
|
|
|
const pickedFiles = createPickedFileAuthorizations()
|
|
|
|
type Deps = {
|
|
killSidecar: () => Promise<void> | void
|
|
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
|
|
getDisplayBackend: () => Promise<string | null>
|
|
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
|
parseMarkdown: (markdown: string) => Promise<string> | string
|
|
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
|
resolveAppPath: (appName: string) => Promise<string | null>
|
|
updater: UpdaterController
|
|
showUpdater: () => Promise<void> | void
|
|
setBackgroundColor: (color: string) => void
|
|
exportDebugLogs: () => Promise<string>
|
|
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
|
}
|
|
|
|
export function registerIpcHandlers(deps: Deps) {
|
|
const updaterSubscriptions = createUpdaterSubscriptions()
|
|
app.once("will-quit", updaterSubscriptions.clear)
|
|
|
|
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
|
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
|
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
|
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
|
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
|
deps.setDefaultServerUrl(url),
|
|
)
|
|
ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
|
|
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
|
|
deps.finishFirstLaunchOnboarding(createDefaultProject),
|
|
)
|
|
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
|
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
|
deps.setDisplayBackend(backend),
|
|
)
|
|
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
|
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
|
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
|
ipcMain.handle("updater-subscribe", (event) => {
|
|
const id = event.sender.id
|
|
updaterSubscriptions.set(
|
|
id,
|
|
deps.updater.subscribe((state) => {
|
|
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
|
|
event.sender.send("updater-state", state)
|
|
}),
|
|
)
|
|
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
|
|
})
|
|
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
|
|
ipcMain.handle("updater-check", () => deps.updater.check())
|
|
ipcMain.handle("updater-install", () => deps.updater.install())
|
|
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
|
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
|
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
|
deps.recordFatalRendererError(error),
|
|
)
|
|
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
|
try {
|
|
const store = getStore(name)
|
|
const value = store.get(key)
|
|
if (value === undefined || value === null) return null
|
|
return typeof value === "string" ? value : JSON.stringify(value)
|
|
} catch {
|
|
return null
|
|
}
|
|
})
|
|
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
|
|
getStore(name).set(key, value)
|
|
})
|
|
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
|
getStore(name).delete(key)
|
|
void removeStoreFileIfEmpty(name)
|
|
})
|
|
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
|
getStore(name).clear()
|
|
void removeStoreFileIfEmpty(name)
|
|
})
|
|
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
|
|
const store = getStore(name)
|
|
return Object.keys(store.store)
|
|
})
|
|
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
|
|
const store = getStore(name)
|
|
return Object.keys(store.store).length
|
|
})
|
|
|
|
ipcMain.handle(
|
|
"open-directory-picker",
|
|
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
|
const result = await dialog.showOpenDialog({
|
|
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
|
title: opts?.title ?? "Choose a folder",
|
|
defaultPath: opts?.defaultPath,
|
|
})
|
|
if (result.canceled) return null
|
|
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
|
},
|
|
)
|
|
|
|
ipcMain.handle(
|
|
"open-file-picker",
|
|
async (
|
|
event: IpcMainInvokeEvent,
|
|
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
|
|
) => {
|
|
const result = await dialog.showOpenDialog({
|
|
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
|
title: opts?.title ?? "Choose a file",
|
|
defaultPath: opts?.defaultPath,
|
|
filters: pickerFilters(opts?.extensions),
|
|
})
|
|
if (result.canceled) return null
|
|
const files = await Promise.all(
|
|
result.filePaths.map(async (filePath) => ({
|
|
path: filePath,
|
|
name: basename(filePath),
|
|
size: (await stat(filePath)).size,
|
|
})),
|
|
)
|
|
assertAttachmentBudget(files)
|
|
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
|
return { token, files }
|
|
},
|
|
)
|
|
|
|
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
|
|
return pickedFiles.read(event.sender.id, token, filePath)
|
|
})
|
|
|
|
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
|
|
pickedFiles.release(event.sender.id, token)
|
|
})
|
|
|
|
ipcMain.handle(
|
|
"save-file-picker",
|
|
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
|
const result = await dialog.showSaveDialog({
|
|
title: opts?.title ?? "Save file",
|
|
defaultPath: opts?.defaultPath,
|
|
})
|
|
if (result.canceled) return null
|
|
return result.filePath ?? null
|
|
},
|
|
)
|
|
|
|
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
|
|
void shell.openExternal(url)
|
|
})
|
|
|
|
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
|
|
if (!app) return shell.openPath(path)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const [cmd, args] =
|
|
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
|
|
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
|
|
})
|
|
})
|
|
|
|
ipcMain.handle("read-clipboard-image", () => {
|
|
const image = clipboard.readImage()
|
|
if (image.isEmpty()) return null
|
|
const buffer = image.toPNG().buffer
|
|
const size = image.getSize()
|
|
return { buffer, width: size.width, height: size.height }
|
|
})
|
|
|
|
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
|
|
new Notification({ title, body }).show()
|
|
})
|
|
|
|
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
|
|
|
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
|
|
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
|
|
})
|
|
|
|
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
return win?.isFocused() ?? false
|
|
})
|
|
|
|
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
win?.focus()
|
|
})
|
|
|
|
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
win?.show()
|
|
})
|
|
|
|
ipcMain.on("relaunch", () => {
|
|
deps.relaunch()
|
|
})
|
|
|
|
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
|
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
|
|
event.sender.setZoomFactor(factor)
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
if (!win) return
|
|
updateTitlebar(win)
|
|
})
|
|
ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
|
|
ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
|
|
setPinchZoomEnabled(enabled)
|
|
})
|
|
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
if (!win) return
|
|
setTitlebar(win, theme)
|
|
})
|
|
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
|
|
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
|
|
checkForUpdates: () => void deps.showUpdater(),
|
|
relaunch: deps.relaunch,
|
|
})
|
|
})
|
|
}
|
|
|
|
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
|
win.webContents.send("menu-command", id)
|
|
}
|
|
|
|
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
|
win.webContents.send("deep-link", urls)
|
|
}
|