ipc.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { execFile } from "node:child_process"
  2. import { stat } from "node:fs/promises"
  3. import { basename } from "node:path"
  4. import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
  5. import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
  6. import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
  7. import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
  8. import { runDesktopMenuAction } from "./desktop-menu-actions"
  9. import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
  10. import { getStore } from "./store"
  11. import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
  12. import type { UpdaterController } from "./updater-controller"
  13. import { createUpdaterSubscriptions } from "./updater-subscriptions"
  14. const pickerFilters = (ext?: string[]) => {
  15. if (!ext || ext.length === 0) return undefined
  16. return [{ name: "Files", extensions: ext }]
  17. }
  18. const pickedFiles = createPickedFileAuthorizations()
  19. type Deps = {
  20. killSidecar: () => Promise<void> | void
  21. relaunch: () => void
  22. awaitInitialization: () => Promise<ServerReadyData>
  23. consumeInitialDeepLinks: () => Promise<string[]> | string[]
  24. getDefaultServerUrl: () => Promise<string | null> | string | null
  25. setDefaultServerUrl: (url: string | null) => Promise<void> | void
  26. getDisplayBackend: () => Promise<string | null>
  27. setDisplayBackend: (backend: string | null) => Promise<void> | void
  28. parseMarkdown: (markdown: string) => Promise<string> | string
  29. checkAppExists: (appName: string) => Promise<boolean> | boolean
  30. resolveAppPath: (appName: string) => Promise<string | null>
  31. updater: UpdaterController
  32. showUpdater: () => Promise<void> | void
  33. setBackgroundColor: (color: string) => void
  34. exportDebugLogs: () => Promise<string>
  35. recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
  36. }
  37. export function registerIpcHandlers(deps: Deps) {
  38. const updaterSubscriptions = createUpdaterSubscriptions()
  39. app.once("will-quit", updaterSubscriptions.clear)
  40. ipcMain.handle("kill-sidecar", () => deps.killSidecar())
  41. ipcMain.handle("await-initialization", () => deps.awaitInitialization())
  42. ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
  43. ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
  44. ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
  45. deps.setDefaultServerUrl(url),
  46. )
  47. ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
  48. ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
  49. deps.setDisplayBackend(backend),
  50. )
  51. ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
  52. ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
  53. ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
  54. ipcMain.handle("updater-subscribe", (event) => {
  55. const id = event.sender.id
  56. updaterSubscriptions.set(
  57. id,
  58. deps.updater.subscribe((state) => {
  59. if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
  60. event.sender.send("updater-state", state)
  61. }),
  62. )
  63. event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
  64. })
  65. ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
  66. ipcMain.handle("updater-check", () => deps.updater.check())
  67. ipcMain.handle("updater-install", () => deps.updater.install())
  68. ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
  69. ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
  70. ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
  71. deps.recordFatalRendererError(error),
  72. )
  73. ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
  74. try {
  75. const store = getStore(name)
  76. const value = store.get(key)
  77. if (value === undefined || value === null) return null
  78. return typeof value === "string" ? value : JSON.stringify(value)
  79. } catch {
  80. return null
  81. }
  82. })
  83. ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
  84. getStore(name).set(key, value)
  85. })
  86. ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
  87. getStore(name).delete(key)
  88. })
  89. ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
  90. getStore(name).clear()
  91. })
  92. ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
  93. const store = getStore(name)
  94. return Object.keys(store.store)
  95. })
  96. ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
  97. const store = getStore(name)
  98. return Object.keys(store.store).length
  99. })
  100. ipcMain.handle(
  101. "open-directory-picker",
  102. async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
  103. const result = await dialog.showOpenDialog({
  104. properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
  105. title: opts?.title ?? "Choose a folder",
  106. defaultPath: opts?.defaultPath,
  107. })
  108. if (result.canceled) return null
  109. return opts?.multiple ? result.filePaths : result.filePaths[0]
  110. },
  111. )
  112. ipcMain.handle(
  113. "open-file-picker",
  114. async (
  115. event: IpcMainInvokeEvent,
  116. opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
  117. ) => {
  118. const result = await dialog.showOpenDialog({
  119. properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
  120. title: opts?.title ?? "Choose a file",
  121. defaultPath: opts?.defaultPath,
  122. filters: pickerFilters(opts?.extensions),
  123. })
  124. if (result.canceled) return null
  125. const files = await Promise.all(
  126. result.filePaths.map(async (filePath) => ({
  127. path: filePath,
  128. name: basename(filePath),
  129. size: (await stat(filePath)).size,
  130. })),
  131. )
  132. assertAttachmentBudget(files)
  133. const token = pickedFiles.add(event.sender.id, result.filePaths)
  134. return { token, files }
  135. },
  136. )
  137. ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
  138. return pickedFiles.read(event.sender.id, token, filePath)
  139. })
  140. ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
  141. pickedFiles.release(event.sender.id, token)
  142. })
  143. ipcMain.handle(
  144. "save-file-picker",
  145. async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
  146. const result = await dialog.showSaveDialog({
  147. title: opts?.title ?? "Save file",
  148. defaultPath: opts?.defaultPath,
  149. })
  150. if (result.canceled) return null
  151. return result.filePath ?? null
  152. },
  153. )
  154. ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
  155. void shell.openExternal(url)
  156. })
  157. ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
  158. if (!app) return shell.openPath(path)
  159. await new Promise<void>((resolve, reject) => {
  160. const [cmd, args] =
  161. process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
  162. execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
  163. })
  164. })
  165. ipcMain.handle("read-clipboard-image", () => {
  166. const image = clipboard.readImage()
  167. if (image.isEmpty()) return null
  168. const buffer = image.toPNG().buffer
  169. const size = image.getSize()
  170. return { buffer, width: size.width, height: size.height }
  171. })
  172. ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
  173. new Notification({ title, body }).show()
  174. })
  175. ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
  176. ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
  177. const win = BrowserWindow.fromWebContents(event.sender)
  178. return win?.isFocused() ?? false
  179. })
  180. ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
  181. const win = BrowserWindow.fromWebContents(event.sender)
  182. win?.focus()
  183. })
  184. ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
  185. const win = BrowserWindow.fromWebContents(event.sender)
  186. win?.show()
  187. })
  188. ipcMain.on("relaunch", () => {
  189. deps.relaunch()
  190. })
  191. ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
  192. ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
  193. event.sender.setZoomFactor(factor)
  194. const win = BrowserWindow.fromWebContents(event.sender)
  195. if (!win) return
  196. updateTitlebar(win)
  197. })
  198. ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
  199. ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
  200. setPinchZoomEnabled(enabled)
  201. })
  202. ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
  203. const win = BrowserWindow.fromWebContents(event.sender)
  204. if (!win) return
  205. setTitlebar(win, theme)
  206. })
  207. ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
  208. runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
  209. checkForUpdates: () => void deps.showUpdater(),
  210. relaunch: deps.relaunch,
  211. })
  212. })
  213. }
  214. export function sendMenuCommand(win: BrowserWindow, id: string) {
  215. win.webContents.send("menu-command", id)
  216. }
  217. export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
  218. win.webContents.send("deep-link", urls)
  219. }