@neurocode-ai/desktop 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +19 -0
  3. package/electron-builder.config.test.ts +48 -0
  4. package/electron-builder.config.ts +146 -0
  5. package/electron.vite.config.ts +106 -0
  6. package/icons/README.md +14 -0
  7. package/icons/beta/128x128.png +0 -0
  8. package/icons/beta/128x128@2x.png +0 -0
  9. package/icons/beta/32x32.png +0 -0
  10. package/icons/beta/64x64.png +0 -0
  11. package/icons/beta/Square107x107Logo.png +0 -0
  12. package/icons/beta/Square142x142Logo.png +0 -0
  13. package/icons/beta/Square150x150Logo.png +0 -0
  14. package/icons/beta/Square284x284Logo.png +0 -0
  15. package/icons/beta/Square30x30Logo.png +0 -0
  16. package/icons/beta/Square310x310Logo.png +0 -0
  17. package/icons/beta/Square44x44Logo.png +0 -0
  18. package/icons/beta/Square71x71Logo.png +0 -0
  19. package/icons/beta/Square89x89Logo.png +0 -0
  20. package/icons/beta/StoreLogo.png +0 -0
  21. package/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  22. package/icons/beta/android/mipmap-hdpi/ic_launcher.png +0 -0
  23. package/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  24. package/icons/beta/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  25. package/icons/beta/android/mipmap-mdpi/ic_launcher.png +0 -0
  26. package/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  27. package/icons/beta/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  28. package/icons/beta/android/mipmap-xhdpi/ic_launcher.png +0 -0
  29. package/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  30. package/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  31. package/icons/beta/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  32. package/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  33. package/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  34. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  35. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  36. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  37. package/icons/beta/android/values/ic_launcher_background.xml +4 -0
  38. package/icons/beta/dock.png +0 -0
  39. package/icons/beta/icon.icns +0 -0
  40. package/icons/beta/icon.ico +0 -0
  41. package/icons/beta/icon.png +0 -0
  42. package/icons/beta/ios/AppIcon-20x20@1x.png +0 -0
  43. package/icons/beta/ios/AppIcon-20x20@2x-1.png +0 -0
  44. package/icons/beta/ios/AppIcon-20x20@2x.png +0 -0
  45. package/icons/beta/ios/AppIcon-20x20@3x.png +0 -0
  46. package/icons/beta/ios/AppIcon-29x29@1x.png +0 -0
  47. package/icons/beta/ios/AppIcon-29x29@2x-1.png +0 -0
  48. package/icons/beta/ios/AppIcon-29x29@2x.png +0 -0
  49. package/icons/beta/ios/AppIcon-29x29@3x.png +0 -0
  50. package/icons/beta/ios/AppIcon-40x40@1x.png +0 -0
  51. package/icons/beta/ios/AppIcon-40x40@2x-1.png +0 -0
  52. package/icons/beta/ios/AppIcon-40x40@2x.png +0 -0
  53. package/icons/beta/ios/AppIcon-40x40@3x.png +0 -0
  54. package/icons/beta/ios/AppIcon-512@2x.png +0 -0
  55. package/icons/beta/ios/AppIcon-60x60@2x.png +0 -0
  56. package/icons/beta/ios/AppIcon-60x60@3x.png +0 -0
  57. package/icons/beta/ios/AppIcon-76x76@1x.png +0 -0
  58. package/icons/beta/ios/AppIcon-76x76@2x.png +0 -0
  59. package/icons/beta/ios/AppIcon-83.5x83.5@2x.png +0 -0
  60. package/icons/dev/128x128.png +0 -0
  61. package/icons/dev/128x128@2x.png +0 -0
  62. package/icons/dev/32x32.png +0 -0
  63. package/icons/dev/64x64.png +0 -0
  64. package/icons/dev/Square107x107Logo.png +0 -0
  65. package/icons/dev/Square142x142Logo.png +0 -0
  66. package/icons/dev/Square150x150Logo.png +0 -0
  67. package/icons/dev/Square284x284Logo.png +0 -0
  68. package/icons/dev/Square30x30Logo.png +0 -0
  69. package/icons/dev/Square310x310Logo.png +0 -0
  70. package/icons/dev/Square44x44Logo.png +0 -0
  71. package/icons/dev/Square71x71Logo.png +0 -0
  72. package/icons/dev/Square89x89Logo.png +0 -0
  73. package/icons/dev/StoreLogo.png +0 -0
  74. package/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  75. package/icons/dev/android/mipmap-hdpi/ic_launcher.png +0 -0
  76. package/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  77. package/icons/dev/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  78. package/icons/dev/android/mipmap-mdpi/ic_launcher.png +0 -0
  79. package/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  80. package/icons/dev/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  81. package/icons/dev/android/mipmap-xhdpi/ic_launcher.png +0 -0
  82. package/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  83. package/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  84. package/icons/dev/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  85. package/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  86. package/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  87. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  88. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  89. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  90. package/icons/dev/android/values/ic_launcher_background.xml +4 -0
  91. package/icons/dev/dock.png +0 -0
  92. package/icons/dev/icon.icns +0 -0
  93. package/icons/dev/icon.ico +0 -0
  94. package/icons/dev/icon.png +0 -0
  95. package/icons/dev/ios/AppIcon-20x20@1x.png +0 -0
  96. package/icons/dev/ios/AppIcon-20x20@2x-1.png +0 -0
  97. package/icons/dev/ios/AppIcon-20x20@2x.png +0 -0
  98. package/icons/dev/ios/AppIcon-20x20@3x.png +0 -0
  99. package/icons/dev/ios/AppIcon-29x29@1x.png +0 -0
  100. package/icons/dev/ios/AppIcon-29x29@2x-1.png +0 -0
  101. package/icons/dev/ios/AppIcon-29x29@2x.png +0 -0
  102. package/icons/dev/ios/AppIcon-29x29@3x.png +0 -0
  103. package/icons/dev/ios/AppIcon-40x40@1x.png +0 -0
  104. package/icons/dev/ios/AppIcon-40x40@2x-1.png +0 -0
  105. package/icons/dev/ios/AppIcon-40x40@2x.png +0 -0
  106. package/icons/dev/ios/AppIcon-40x40@3x.png +0 -0
  107. package/icons/dev/ios/AppIcon-512@2x.png +0 -0
  108. package/icons/dev/ios/AppIcon-60x60@2x.png +0 -0
  109. package/icons/dev/ios/AppIcon-60x60@3x.png +0 -0
  110. package/icons/dev/ios/AppIcon-76x76@1x.png +0 -0
  111. package/icons/dev/ios/AppIcon-76x76@2x.png +0 -0
  112. package/icons/dev/ios/AppIcon-83.5x83.5@2x.png +0 -0
  113. package/icons/prod/128x128.png +0 -0
  114. package/icons/prod/128x128@2x.png +0 -0
  115. package/icons/prod/32x32.png +0 -0
  116. package/icons/prod/64x64.png +0 -0
  117. package/icons/prod/Square107x107Logo.png +0 -0
  118. package/icons/prod/Square142x142Logo.png +0 -0
  119. package/icons/prod/Square150x150Logo.png +0 -0
  120. package/icons/prod/Square284x284Logo.png +0 -0
  121. package/icons/prod/Square30x30Logo.png +0 -0
  122. package/icons/prod/Square310x310Logo.png +0 -0
  123. package/icons/prod/Square44x44Logo.png +0 -0
  124. package/icons/prod/Square71x71Logo.png +0 -0
  125. package/icons/prod/Square89x89Logo.png +0 -0
  126. package/icons/prod/StoreLogo.png +0 -0
  127. package/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  128. package/icons/prod/android/mipmap-hdpi/ic_launcher.png +0 -0
  129. package/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  130. package/icons/prod/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  131. package/icons/prod/android/mipmap-mdpi/ic_launcher.png +0 -0
  132. package/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  133. package/icons/prod/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  134. package/icons/prod/android/mipmap-xhdpi/ic_launcher.png +0 -0
  135. package/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  136. package/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  137. package/icons/prod/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  138. package/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  139. package/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  140. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  141. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  142. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  143. package/icons/prod/android/values/ic_launcher_background.xml +4 -0
  144. package/icons/prod/dock.png +0 -0
  145. package/icons/prod/icon.icns +0 -0
  146. package/icons/prod/icon.ico +0 -0
  147. package/icons/prod/icon.png +0 -0
  148. package/icons/prod/ios/AppIcon-20x20@1x.png +0 -0
  149. package/icons/prod/ios/AppIcon-20x20@2x-1.png +0 -0
  150. package/icons/prod/ios/AppIcon-20x20@2x.png +0 -0
  151. package/icons/prod/ios/AppIcon-20x20@3x.png +0 -0
  152. package/icons/prod/ios/AppIcon-29x29@1x.png +0 -0
  153. package/icons/prod/ios/AppIcon-29x29@2x-1.png +0 -0
  154. package/icons/prod/ios/AppIcon-29x29@2x.png +0 -0
  155. package/icons/prod/ios/AppIcon-29x29@3x.png +0 -0
  156. package/icons/prod/ios/AppIcon-40x40@1x.png +0 -0
  157. package/icons/prod/ios/AppIcon-40x40@2x-1.png +0 -0
  158. package/icons/prod/ios/AppIcon-40x40@2x.png +0 -0
  159. package/icons/prod/ios/AppIcon-40x40@3x.png +0 -0
  160. package/icons/prod/ios/AppIcon-512@2x.png +0 -0
  161. package/icons/prod/ios/AppIcon-60x60@2x.png +0 -0
  162. package/icons/prod/ios/AppIcon-60x60@3x.png +0 -0
  163. package/icons/prod/ios/AppIcon-76x76@1x.png +0 -0
  164. package/icons/prod/ios/AppIcon-76x76@2x.png +0 -0
  165. package/icons/prod/ios/AppIcon-83.5x83.5@2x.png +0 -0
  166. package/package.json +75 -0
  167. package/resources/entitlements.plist +18 -0
  168. package/resources/linux/opencode-desktop.desktop +10 -0
  169. package/scripts/copy-bundles.ts +12 -0
  170. package/scripts/copy-icons.ts +12 -0
  171. package/scripts/copy-metainfo.ts +47 -0
  172. package/scripts/finalize-latest-json.ts +219 -0
  173. package/scripts/finalize-latest-yml.ts +124 -0
  174. package/scripts/prebuild.ts +10 -0
  175. package/scripts/predev.ts +5 -0
  176. package/scripts/prepare.ts +9 -0
  177. package/scripts/utils.ts +77 -0
  178. package/src/main/apps.ts +136 -0
  179. package/src/main/attachment-picker.test.ts +87 -0
  180. package/src/main/attachment-picker.ts +56 -0
  181. package/src/main/constants.ts +7 -0
  182. package/src/main/debug.ts +95 -0
  183. package/src/main/desktop-menu-actions.ts +84 -0
  184. package/src/main/env.d.ts +19 -0
  185. package/src/main/index.test.ts +37 -0
  186. package/src/main/index.ts +399 -0
  187. package/src/main/initialization.ts +6 -0
  188. package/src/main/install-state.test.ts +19 -0
  189. package/src/main/install-state.ts +8 -0
  190. package/src/main/ipc.ts +274 -0
  191. package/src/main/logging.ts +205 -0
  192. package/src/main/markdown.ts +16 -0
  193. package/src/main/menu.ts +67 -0
  194. package/src/main/migrate.ts +91 -0
  195. package/src/main/onboarding.ts +45 -0
  196. package/src/main/server.ts +238 -0
  197. package/src/main/shell-env.test.ts +50 -0
  198. package/src/main/shell-env.ts +101 -0
  199. package/src/main/sidecar.ts +157 -0
  200. package/src/main/store-cleanup.test.ts +93 -0
  201. package/src/main/store-cleanup.ts +94 -0
  202. package/src/main/store-keys.ts +7 -0
  203. package/src/main/store.ts +35 -0
  204. package/src/main/unresponsive.ts +70 -0
  205. package/src/main/updater-controller.test.ts +111 -0
  206. package/src/main/updater-controller.ts +97 -0
  207. package/src/main/updater-subscriptions.test.ts +16 -0
  208. package/src/main/updater-subscriptions.ts +20 -0
  209. package/src/main/updater.ts +85 -0
  210. package/src/main/window-registry.test.ts +91 -0
  211. package/src/main/window-registry.ts +47 -0
  212. package/src/main/window-state.ts +29 -0
  213. package/src/main/windows.ts +496 -0
  214. package/src/main/wsl/ipc.ts +103 -0
  215. package/src/main/wsl/policy.ts +33 -0
  216. package/src/main/wsl/runtime.ts +400 -0
  217. package/src/main/wsl/servers.test.ts +231 -0
  218. package/src/main/wsl/servers.ts +521 -0
  219. package/src/main/wsl/sidecar.ts +129 -0
  220. package/src/main/wsl/startup.ts +31 -0
  221. package/src/preload/index.ts +128 -0
  222. package/src/preload/types.ts +109 -0
  223. package/src/renderer/cli.ts +12 -0
  224. package/src/renderer/env.d.ts +10 -0
  225. package/src/renderer/html.test.ts +62 -0
  226. package/src/renderer/i18n/ar.ts +26 -0
  227. package/src/renderer/i18n/br.ts +27 -0
  228. package/src/renderer/i18n/bs.ts +28 -0
  229. package/src/renderer/i18n/da.ts +28 -0
  230. package/src/renderer/i18n/de.ts +28 -0
  231. package/src/renderer/i18n/en.ts +27 -0
  232. package/src/renderer/i18n/es.ts +27 -0
  233. package/src/renderer/i18n/fr.ts +28 -0
  234. package/src/renderer/i18n/index.ts +194 -0
  235. package/src/renderer/i18n/ja.ts +28 -0
  236. package/src/renderer/i18n/ko.ts +27 -0
  237. package/src/renderer/i18n/no.ts +28 -0
  238. package/src/renderer/i18n/pl.ts +28 -0
  239. package/src/renderer/i18n/ru.ts +27 -0
  240. package/src/renderer/i18n/uk.ts +28 -0
  241. package/src/renderer/i18n/zh.ts +26 -0
  242. package/src/renderer/i18n/zht.ts +26 -0
  243. package/src/renderer/index.html +21 -0
  244. package/src/renderer/index.tsx +464 -0
  245. package/src/renderer/initialization.test.ts +73 -0
  246. package/src/renderer/initialization.ts +22 -0
  247. package/src/renderer/onboarding.tsx +53 -0
  248. package/src/renderer/styles.css +0 -0
  249. package/src/renderer/webview-zoom.ts +138 -0
  250. package/src/renderer/wsl/connections.test.ts +43 -0
  251. package/src/renderer/wsl/connections.ts +28 -0
  252. package/sst-env.d.ts +10 -0
  253. package/tsconfig.json +23 -0
@@ -0,0 +1,274 @@
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 "@neurocode-ai/app/desktop-menu"
7
+
8
+ import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
9
+ import { runDesktopMenuAction } from "./desktop-menu-actions"
10
+ import { setForceFocus } from "./debug"
11
+ import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
12
+ import { getStore, removeStoreFileIfEmpty } from "./store"
13
+ import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
14
+ import type { UpdaterController } from "./updater-controller"
15
+ import { createUpdaterSubscriptions } from "./updater-subscriptions"
16
+
17
+ const pickerFilters = (ext?: string[]) => {
18
+ if (!ext || ext.length === 0) return undefined
19
+ return [{ name: "Files", extensions: ext }]
20
+ }
21
+
22
+ const pickedFiles = createPickedFileAuthorizations()
23
+
24
+ type Deps = {
25
+ killSidecar: () => Promise<void> | void
26
+ relaunch: () => void
27
+ awaitInitialization: () => Promise<ServerReadyData>
28
+ consumeInitialDeepLinks: () => Promise<string[]> | string[]
29
+ getDefaultServerUrl: () => Promise<string | null> | string | null
30
+ setDefaultServerUrl: (url: string | null) => Promise<void> | void
31
+ isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
32
+ finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
33
+ isOldLayoutEligible: () => Promise<boolean> | boolean
34
+ getDisplayBackend: () => Promise<string | null>
35
+ setDisplayBackend: (backend: string | null) => Promise<void> | void
36
+ parseMarkdown: (markdown: string) => Promise<string> | string
37
+ checkAppExists: (appName: string) => Promise<boolean> | boolean
38
+ resolveAppPath: (appName: string) => Promise<string | null>
39
+ updater: UpdaterController
40
+ showUpdater: () => Promise<void> | void
41
+ setBackgroundColor: (color: string) => void
42
+ exportDebugLogs: () => Promise<string>
43
+ recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
44
+ }
45
+
46
+ export function registerIpcHandlers(deps: Deps) {
47
+ const updaterSubscriptions = createUpdaterSubscriptions()
48
+ app.once("will-quit", updaterSubscriptions.clear)
49
+
50
+ ipcMain.handle("kill-sidecar", () => deps.killSidecar())
51
+ ipcMain.handle("await-initialization", () => deps.awaitInitialization())
52
+ ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
53
+ ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
54
+ ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
55
+ deps.setDefaultServerUrl(url),
56
+ )
57
+ ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
58
+ ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
59
+ deps.finishFirstLaunchOnboarding(createDefaultProject),
60
+ )
61
+ ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible())
62
+ ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
63
+ ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
64
+ deps.setDisplayBackend(backend),
65
+ )
66
+ ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
67
+ ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
68
+ ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
69
+ ipcMain.handle("updater-subscribe", (event) => {
70
+ const id = event.sender.id
71
+ updaterSubscriptions.set(
72
+ id,
73
+ deps.updater.subscribe((state) => {
74
+ if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
75
+ event.sender.send("updater-state", state)
76
+ }),
77
+ )
78
+ event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
79
+ })
80
+ ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
81
+ ipcMain.handle("updater-check", () => deps.updater.check())
82
+ ipcMain.handle("updater-install", () => deps.updater.install())
83
+ ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
84
+ ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
85
+ ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
86
+ setForceFocus(event.sender, enabled),
87
+ )
88
+ ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
89
+ deps.recordFatalRendererError(error),
90
+ )
91
+ ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
92
+ try {
93
+ const store = getStore(name)
94
+ const value = store.get(key)
95
+ if (value === undefined || value === null) return null
96
+ return typeof value === "string" ? value : JSON.stringify(value)
97
+ } catch {
98
+ return null
99
+ }
100
+ })
101
+ ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
102
+ getStore(name).set(key, value)
103
+ })
104
+ ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
105
+ getStore(name).delete(key)
106
+ void removeStoreFileIfEmpty(name)
107
+ })
108
+ ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
109
+ getStore(name).clear()
110
+ void removeStoreFileIfEmpty(name)
111
+ })
112
+ ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
113
+ const store = getStore(name)
114
+ return Object.keys(store.store)
115
+ })
116
+ ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
117
+ const store = getStore(name)
118
+ return Object.keys(store.store).length
119
+ })
120
+
121
+ ipcMain.handle(
122
+ "open-directory-picker",
123
+ async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
124
+ const result = await dialog.showOpenDialog({
125
+ properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
126
+ title: opts?.title ?? "Choose a folder",
127
+ defaultPath: opts?.defaultPath,
128
+ })
129
+ if (result.canceled) return null
130
+ return opts?.multiple ? result.filePaths : result.filePaths[0]
131
+ },
132
+ )
133
+
134
+ ipcMain.handle(
135
+ "open-file-picker",
136
+ async (
137
+ event: IpcMainInvokeEvent,
138
+ opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
139
+ ) => {
140
+ const result = await dialog.showOpenDialog({
141
+ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
142
+ title: opts?.title ?? "Choose a file",
143
+ defaultPath: opts?.defaultPath,
144
+ filters: pickerFilters(opts?.extensions),
145
+ })
146
+ if (result.canceled) return null
147
+ const files = await Promise.all(
148
+ result.filePaths.map(async (filePath) => ({
149
+ path: filePath,
150
+ name: basename(filePath),
151
+ size: (await stat(filePath)).size,
152
+ })),
153
+ )
154
+ assertAttachmentBudget(files)
155
+ const token = pickedFiles.add(event.sender.id, result.filePaths)
156
+ return { token, files }
157
+ },
158
+ )
159
+
160
+ ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
161
+ return pickedFiles.read(event.sender.id, token, filePath)
162
+ })
163
+
164
+ ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
165
+ pickedFiles.release(event.sender.id, token)
166
+ })
167
+
168
+ ipcMain.handle(
169
+ "save-file-picker",
170
+ async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
171
+ const result = await dialog.showSaveDialog({
172
+ title: opts?.title ?? "Save file",
173
+ defaultPath: opts?.defaultPath,
174
+ })
175
+ if (result.canceled) return null
176
+ return result.filePath ?? null
177
+ },
178
+ )
179
+
180
+ ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
181
+ void shell.openExternal(url)
182
+ })
183
+
184
+ ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
185
+ if (!app) return shell.openPath(path)
186
+ await new Promise<void>((resolve, reject) => {
187
+ const [cmd, args] =
188
+ process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
189
+ execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
190
+ })
191
+ })
192
+
193
+ ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
194
+ const exists = await stat(path).then(
195
+ () => true,
196
+ () => false,
197
+ )
198
+ if (!exists) return false
199
+ shell.showItemInFolder(path)
200
+ return true
201
+ })
202
+
203
+ ipcMain.handle("read-clipboard-image", () => {
204
+ const image = clipboard.readImage()
205
+ if (image.isEmpty()) return null
206
+ const buffer = image.toPNG().buffer
207
+ const size = image.getSize()
208
+ return { buffer, width: size.width, height: size.height }
209
+ })
210
+
211
+ ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
212
+ new Notification({ title, body }).show()
213
+ })
214
+
215
+ ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
216
+
217
+ ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
218
+ const win = BrowserWindow.fromWebContents(event.sender)
219
+ if (!win) throw new Error("Window not found")
220
+ const id = getWindowID(win)
221
+ if (!id) throw new Error("Window ID not found")
222
+ return id
223
+ })
224
+
225
+ ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
226
+ const win = BrowserWindow.fromWebContents(event.sender)
227
+ return win?.isFocused() ?? false
228
+ })
229
+
230
+ ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
231
+ const win = BrowserWindow.fromWebContents(event.sender)
232
+ win?.focus()
233
+ })
234
+
235
+ ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
236
+ const win = BrowserWindow.fromWebContents(event.sender)
237
+ win?.show()
238
+ })
239
+
240
+ ipcMain.on("relaunch", () => {
241
+ deps.relaunch()
242
+ })
243
+
244
+ ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
245
+ ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
246
+ event.sender.setZoomFactor(factor)
247
+ const win = BrowserWindow.fromWebContents(event.sender)
248
+ if (!win) return
249
+ updateTitlebar(win)
250
+ })
251
+ ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
252
+ ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
253
+ setPinchZoomEnabled(enabled)
254
+ })
255
+ ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
256
+ const win = BrowserWindow.fromWebContents(event.sender)
257
+ if (!win) return
258
+ setTitlebar(win, theme)
259
+ })
260
+ ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
261
+ runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
262
+ checkForUpdates: () => void deps.showUpdater(),
263
+ relaunch: deps.relaunch,
264
+ })
265
+ })
266
+ }
267
+
268
+ export function sendMenuCommand(win: BrowserWindow, id: string) {
269
+ win.webContents.send("menu-command", id)
270
+ }
271
+
272
+ export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
273
+ win.webContents.send("deep-link", urls)
274
+ }
@@ -0,0 +1,205 @@
1
+ import { MainLogger } from "electron-log"
2
+ import log from "electron-log/main.js"
3
+ import { app, crashReporter, netLog, shell } from "electron"
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
5
+ import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
6
+ import { dirname, join } from "node:path"
7
+ import { homedir } from "node:os"
8
+
9
+ const MAX_LOG_AGE_DAYS = 7
10
+ const TAIL_LINES = 1000
11
+ const EXPORT_WINDOW = 24 * 60 * 60 * 1000
12
+ const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024
13
+ const NET_LOG_SIZE = 20 * 1024 * 1024
14
+
15
+ let root = ""
16
+ let run = ""
17
+ let netLogPath: string | undefined
18
+
19
+ let logger: MainLogger
20
+ export const getLogger = () => logger
21
+
22
+ export function initLogging() {
23
+ initRunDirectory()
24
+ log.transports.file.maxSize = 5 * 1024 * 1024
25
+ log.transports.file.resolvePathFn = (_vars, message) =>
26
+ join(
27
+ run,
28
+ `${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
29
+ )
30
+ log.initialize({ preload: false, spyRendererConsole: true })
31
+ initConsoleTransport()
32
+ cleanup()
33
+ return (logger = log)
34
+ }
35
+
36
+ export function initCrashReporter() {
37
+ const dir = join(app.getPath("userData"), "Crashpad")
38
+ mkdirSync(dir, { recursive: true })
39
+ app.setPath("crashDumps", dir)
40
+ crashReporter.start({ uploadToServer: false, compress: true })
41
+ write("crash", "crash reporter started", { path: dir })
42
+ }
43
+
44
+ export async function startNetLog() {
45
+ if (netLog.currentlyLogging) return
46
+ netLogPath = join(run, "network.netlog")
47
+ await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
48
+ write("network", "net log started", { path: netLogPath })
49
+ }
50
+
51
+ export async function exportDebugLogs() {
52
+ const restartNetLog = netLog.currentlyLogging
53
+ if (restartNetLog) {
54
+ await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
55
+ }
56
+
57
+ const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
58
+ try {
59
+ write("main", "exporting debug logs", { output })
60
+ await writeZip(output, [
61
+ { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
62
+ ...collect(root, "desktop"),
63
+ ...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
64
+ ...collect(app.getPath("crashDumps"), "crashpad"),
65
+ ])
66
+ shell.showItemInFolder(output)
67
+ return output
68
+ } finally {
69
+ if (restartNetLog) {
70
+ await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
71
+ }
72
+ }
73
+ }
74
+
75
+ export function write(
76
+ name: string,
77
+ message: string,
78
+ extra?: Record<string, unknown>,
79
+ level: "info" | "warn" | "error" = "info",
80
+ ) {
81
+ if (!run) return
82
+ const scoped = log.scope(safeLogName(name))
83
+ if (extra !== undefined) {
84
+ scoped[level](message, extra)
85
+ return
86
+ }
87
+ scoped[level](message)
88
+ }
89
+
90
+ export function tail(): string {
91
+ try {
92
+ const path = log.transports.file.getFile().path
93
+ const contents = readFileSync(path, "utf8")
94
+ const lines = contents.split("\n")
95
+ return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
96
+ } catch {
97
+ return ""
98
+ }
99
+ }
100
+
101
+ function initRunDirectory() {
102
+ root = join(app.getPath("userData"), "logs")
103
+ run = join(root, stamp())
104
+ mkdirSync(run, { recursive: true })
105
+ }
106
+
107
+ function stamp() {
108
+ return new Date()
109
+ .toISOString()
110
+ .replace(/[-:]/g, "")
111
+ .replace(/\.\d+Z$/, "")
112
+ }
113
+
114
+ function safeLogName(name: string) {
115
+ return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
116
+ }
117
+
118
+ function cleanup() {
119
+ const dir = root || dirname(log.transports.file.getFile().path)
120
+ const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
121
+
122
+ for (const entry of readdirSync(dir)) {
123
+ const file = join(dir, entry)
124
+ try {
125
+ const info = statSync(file)
126
+ if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true })
127
+ } catch {
128
+ continue
129
+ }
130
+ }
131
+ }
132
+
133
+ function manifest() {
134
+ return {
135
+ generated: new Date().toISOString(),
136
+ version: app.getVersion(),
137
+ name: app.getName(),
138
+ packaged: app.isPackaged,
139
+ platform: process.platform,
140
+ arch: process.arch,
141
+ versions: process.versions,
142
+ uptime: process.uptime(),
143
+ userData: app.getPath("userData"),
144
+ logs: root,
145
+ currentRun: run,
146
+ crashDumps: app.getPath("crashDumps"),
147
+ serverLogs: serverLogRoots(),
148
+ netLog: netLogPath,
149
+ }
150
+ }
151
+
152
+ function serverLogRoots() {
153
+ const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
154
+ return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
155
+ }
156
+
157
+ type Entry = { name: string; path?: string; data?: Buffer }
158
+
159
+ function collect(dir: string, prefix: string): Entry[] {
160
+ if (!existsSync(dir)) return []
161
+ const cutoff = Date.now() - EXPORT_WINDOW
162
+ const result: Entry[] = []
163
+ const walk = (current: string) => {
164
+ for (const entry of readdirSync(current)) {
165
+ const file = join(current, entry)
166
+ const info = statSync(file)
167
+ if (info.isDirectory()) {
168
+ walk(file)
169
+ continue
170
+ }
171
+ if (info.mtimeMs < cutoff) continue
172
+ if (info.size > MAX_EXPORT_FILE_SIZE) continue
173
+ if (file.endsWith(".heapsnapshot")) continue
174
+ result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
175
+ }
176
+ }
177
+ walk(dir)
178
+ return result
179
+ }
180
+
181
+ async function writeZip(output: string, entries: Entry[]) {
182
+ const writer = new ZipWriter(new BlobWriter("application/zip"))
183
+ for (const entry of entries) {
184
+ const data = entry.data ?? readFileSync(entry.path!)
185
+ await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
186
+ }
187
+ const zip = await writer.close()
188
+ writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
189
+ }
190
+
191
+ function initConsoleTransport() {
192
+ const write = log.transports.console.writeFn.bind(log.transports.console)
193
+ log.transports.console.writeFn = (options) => {
194
+ try {
195
+ write(options)
196
+ } catch (err) {
197
+ if (!isBrokenPipe(err)) throw err
198
+ log.transports.console.level = false
199
+ }
200
+ }
201
+ }
202
+
203
+ function isBrokenPipe(err: unknown) {
204
+ return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE"
205
+ }
@@ -0,0 +1,16 @@
1
+ import { marked, type Tokens } from "marked"
2
+
3
+ const renderer = new marked.Renderer()
4
+
5
+ renderer.link = ({ href, title, text }: Tokens.Link) => {
6
+ const titleAttr = title ? ` title="${title}"` : ""
7
+ return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
8
+ }
9
+
10
+ export function parseMarkdown(input: string) {
11
+ return marked(input, {
12
+ renderer,
13
+ breaks: false,
14
+ gfm: true,
15
+ })
16
+ }
@@ -0,0 +1,67 @@
1
+ import { BrowserWindow, Menu, shell } from "electron"
2
+ import type { MenuItemConstructorOptions } from "electron"
3
+ import {
4
+ DESKTOP_MENU,
5
+ desktopMenuVisible,
6
+ type DesktopMenuEntry,
7
+ type DesktopMenuRole,
8
+ } from "@neurocode-ai/app/desktop-menu"
9
+
10
+ import { UPDATER_ENABLED } from "./constants"
11
+ import { runDesktopMenuAction } from "./desktop-menu-actions"
12
+
13
+ type Deps = {
14
+ trigger: (id: string) => void
15
+ checkForUpdates: () => void
16
+ relaunch: () => void
17
+ }
18
+
19
+ export function createMenu(deps: Deps) {
20
+ if (process.platform !== "darwin") return
21
+
22
+ const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
23
+ if (menu.role) return { role: nativeRole(menu.role) }
24
+ return {
25
+ label: menu.label,
26
+ submenu: menu.items
27
+ ?.filter((entry) => desktopMenuVisible(entry, "macos"))
28
+ .map((entry) => nativeItem(entry, deps)),
29
+ }
30
+ })
31
+
32
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template))
33
+ }
34
+
35
+ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
36
+ if (entry.type === "separator") return { type: "separator" }
37
+ if (entry.role) return { role: nativeRole(entry.role) }
38
+
39
+ const item: MenuItemConstructorOptions = {
40
+ label: entry.label,
41
+ accelerator: entry.accelerator?.macos,
42
+ enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
43
+ }
44
+
45
+ if (entry.command) {
46
+ const command = entry.command
47
+ item.click = () => deps.trigger(command)
48
+ }
49
+ if (entry.action) {
50
+ const action = entry.action
51
+ item.click = () =>
52
+ runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
53
+ checkForUpdates: deps.checkForUpdates,
54
+ relaunch: deps.relaunch,
55
+ })
56
+ }
57
+ if (entry.href) {
58
+ const href = entry.href
59
+ item.click = () => shell.openExternal(href)
60
+ }
61
+
62
+ return item
63
+ }
64
+
65
+ function nativeRole(role: DesktopMenuRole) {
66
+ return role as NonNullable<MenuItemConstructorOptions["role"]>
67
+ }
@@ -0,0 +1,91 @@
1
+ import { app } from "electron"
2
+ import log from "electron-log/main.js"
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
4
+ import { homedir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { CHANNEL } from "./constants"
7
+ import { getStore } from "./store"
8
+
9
+ const TAURI_MIGRATED_KEY = "tauriMigrated"
10
+
11
+ // Resolve the directory where Tauri stored its .dat files for the given app identifier.
12
+ // Mirrors Tauri's AppLocalData / AppData resolution per OS.
13
+ function tauriDir(id: string) {
14
+ switch (process.platform) {
15
+ case "darwin":
16
+ return join(homedir(), "Library", "Application Support", id)
17
+ case "win32":
18
+ return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id)
19
+ default:
20
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id)
21
+ }
22
+ }
23
+
24
+ // The Tauri app identifier changes between dev/beta/prod builds.
25
+ const TAURI_APP_IDS: Record<string, string> = {
26
+ dev: "ai.neurocode.desktop.dev",
27
+ beta: "ai.neurocode.desktop.beta",
28
+ prod: "ai.neurocode.desktop",
29
+ }
30
+ function tauriAppId() {
31
+ return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.neurocode.desktop.dev"
32
+ }
33
+
34
+ // Migrate a single Tauri .dat file into the corresponding electron-store.
35
+ // `neurocode.settings.dat` is special: it maps to the `neurocode.settings` store
36
+ // (the electron-store name without the `.dat` extension). All other .dat files
37
+ // keep their full filename as the electron-store name so they match what the
38
+ // renderer already passes via IPC (e.g. `"default.dat"`, `"neurocode.global.dat"`).
39
+ function migrateFile(datPath: string, filename: string) {
40
+ let data: Record<string, unknown>
41
+ try {
42
+ data = JSON.parse(readFileSync(datPath, "utf-8"))
43
+ } catch (err) {
44
+ log.warn("tauri migration: failed to parse", filename, err)
45
+ return
46
+ }
47
+
48
+ // neurocode.settings.dat → the electron settings store ("neurocode.settings").
49
+ // All other .dat files keep their full filename as the store name so they match
50
+ // what the renderer passes via IPC (e.g. "default.dat", "neurocode.global.dat").
51
+ const storeName = filename === "neurocode.settings.dat" ? "neurocode.settings" : filename
52
+ const target = getStore(storeName)
53
+ const migrated: string[] = []
54
+ const skipped: string[] = []
55
+
56
+ for (const [key, value] of Object.entries(data)) {
57
+ // Don't overwrite values the user has already set in the Electron app.
58
+ if (target.has(key)) {
59
+ skipped.push(key)
60
+ continue
61
+ }
62
+ target.set(key, value)
63
+ migrated.push(key)
64
+ }
65
+
66
+ log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped })
67
+ }
68
+
69
+ export function migrate() {
70
+ if (getStore().get(TAURI_MIGRATED_KEY)) {
71
+ log.log("tauri migration: already done, skipping")
72
+ return
73
+ }
74
+
75
+ const dir = tauriDir(tauriAppId())
76
+ log.log("tauri migration: starting", { dir })
77
+
78
+ if (!existsSync(dir)) {
79
+ log.log("tauri migration: no tauri data directory found, nothing to migrate")
80
+ getStore().set(TAURI_MIGRATED_KEY, true)
81
+ return
82
+ }
83
+
84
+ for (const filename of readdirSync(dir)) {
85
+ if (!filename.endsWith(".dat")) continue
86
+ migrateFile(join(dir, filename), filename)
87
+ }
88
+
89
+ log.log("tauri migration: complete")
90
+ getStore().set(TAURI_MIGRATED_KEY, true)
91
+ }