@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,29 @@
1
+ export const destroyedWindowURL = "<destroyed>"
2
+
3
+ type WebContentsURLState = {
4
+ isDestroyed(): boolean
5
+ getURL(): string
6
+ }
7
+
8
+ type WindowURLState = {
9
+ isDestroyed(): boolean
10
+ readonly webContents: WebContentsURLState
11
+ }
12
+
13
+ export function safeWebContentsURL(webContents: WebContentsURLState) {
14
+ try {
15
+ if (webContents.isDestroyed()) return destroyedWindowURL
16
+ return webContents.getURL()
17
+ } catch {
18
+ return destroyedWindowURL
19
+ }
20
+ }
21
+
22
+ export function safeWindowURL(win: WindowURLState) {
23
+ try {
24
+ if (win.isDestroyed()) return destroyedWindowURL
25
+ return safeWebContentsURL(win.webContents)
26
+ } catch {
27
+ return destroyedWindowURL
28
+ }
29
+ }
@@ -0,0 +1,496 @@
1
+ import windowState from "electron-window-state"
2
+ import { resolveThemeVariant } from "@neurocode-ai/ui/theme/resolve"
3
+ import type { DesktopTheme } from "@neurocode-ai/ui/theme/types"
4
+ import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
5
+ import { randomUUID } from "node:crypto"
6
+ import { rmSync } from "node:fs"
7
+ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
10
+ import type { TitlebarTheme } from "../preload/types"
11
+ import { exportDebugLogs, write as writeLog } from "./logging"
12
+ import { getStore, removeStoreFile } from "./store"
13
+ import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
14
+ import { createUnresponsiveSampler } from "./unresponsive"
15
+ import { createWindowRegistry } from "./window-registry"
16
+ import { safeWindowURL } from "./window-state"
17
+
18
+ const root = dirname(fileURLToPath(import.meta.url))
19
+ const rendererRoot = join(root, "../renderer")
20
+ const rendererProtocol = "oc"
21
+ const rendererHost = "renderer"
22
+ const clipboardWritePermission = "clipboard-sanitized-write"
23
+ const notificationPermission = "notifications"
24
+ const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
25
+ const oc2Theme = oc2ThemeJson as DesktopTheme
26
+ const oc2Background = {
27
+ light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
28
+ dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
29
+ }
30
+ const documentPolicyHeader = "Document-Policy"
31
+ const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
32
+
33
+ protocol.registerSchemesAsPrivileged([
34
+ {
35
+ scheme: rendererProtocol,
36
+ privileges: {
37
+ secure: true,
38
+ standard: true,
39
+ supportFetchAPI: true,
40
+ stream: true,
41
+ },
42
+ },
43
+ ])
44
+
45
+ let backgroundColor: string | undefined
46
+ let relaunchHandler = () => {
47
+ setAppQuitting()
48
+ app.relaunch()
49
+ app.exit(0)
50
+ }
51
+ const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
52
+ const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
53
+ const windowIDs = new WeakMap<BrowserWindow, string>()
54
+ const registry = createWindowRegistry<BrowserWindow>({
55
+ read: () => getStore().get(WINDOW_IDS_KEY),
56
+ write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
57
+ cleanup: (id) => {
58
+ rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
59
+ removeStoreFile(windowDataFile(id))
60
+ },
61
+ })
62
+ const titlebarHeight = 40
63
+ const maxZoomLevel = 10
64
+ const minZoomLevel = 0.2
65
+
66
+ export function setRelaunchHandler(handler: () => void) {
67
+ relaunchHandler = handler
68
+ }
69
+
70
+ export function setAppQuitting(quitting = true) {
71
+ registry.setQuitting(quitting)
72
+ }
73
+
74
+ export function setBackgroundColor(color: string) {
75
+ backgroundColor = color
76
+ BrowserWindow.getAllWindows().forEach((win) => {
77
+ win.setBackgroundColor(color)
78
+ if (process.platform === "darwin") win.invalidateShadow()
79
+ })
80
+ }
81
+
82
+ export function getBackgroundColor(): string | undefined {
83
+ return backgroundColor
84
+ }
85
+
86
+ function iconsDir() {
87
+ return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
88
+ }
89
+
90
+ function iconPath() {
91
+ const ext = process.platform === "win32" ? "ico" : "png"
92
+ return join(iconsDir(), `icon.${ext}`)
93
+ }
94
+
95
+ function tone() {
96
+ return nativeTheme.shouldUseDarkColors ? "dark" : "light"
97
+ }
98
+
99
+ function defaultBackgroundColor() {
100
+ return oc2Background[tone()]
101
+ }
102
+
103
+ function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
104
+ const mode = theme.mode ?? tone()
105
+ return {
106
+ color: "#00000000",
107
+ symbolColor: mode === "dark" ? "white" : "black",
108
+ height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
109
+ }
110
+ }
111
+
112
+ export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
113
+ titlebarThemes.set(win, theme)
114
+ // macOS draws the window frame hairline and shadow using the NSWindow
115
+ // appearance, which follows nativeTheme rather than the rendered content.
116
+ // Align it with the app theme so a light app on a dark system does not get
117
+ // the dark-appearance border and shadow. A "system" scheme must map to
118
+ // "system" (not the resolved mode) or prefers-color-scheme stops tracking
119
+ // OS appearance changes in the renderer.
120
+ if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
121
+ updateTitlebar(win)
122
+ }
123
+
124
+ export function updateTitlebar(win: BrowserWindow) {
125
+ if (process.platform !== "win32") return
126
+ win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
127
+ }
128
+
129
+ export function setPinchZoomEnabled(enabled: boolean) {
130
+ getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
131
+ for (const win of BrowserWindow.getAllWindows()) {
132
+ pinchZoomEnabled.set(win, enabled)
133
+ win.webContents.send("pinch-zoom-enabled-changed", enabled)
134
+ if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
135
+ updateZoom(win)
136
+ }
137
+ }
138
+
139
+ export function getPinchZoomEnabled() {
140
+ return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
141
+ }
142
+
143
+ export function getWindowID(win: BrowserWindow) {
144
+ return windowIDs.get(win)
145
+ }
146
+
147
+ export function getLastFocusedWindow() {
148
+ const focused = BrowserWindow.getFocusedWindow()
149
+ if (focused) return focused
150
+ const win = registry.lastFocused()
151
+ if (!win || win.isDestroyed()) return null
152
+ return win
153
+ }
154
+
155
+ export function restoreMainWindows() {
156
+ const ids = registry.persisted()
157
+ return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
158
+ }
159
+
160
+ export function setDockIcon() {
161
+ if (process.platform !== "darwin") return
162
+ const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
163
+ if (!icon.isEmpty()) app.dock?.setIcon(icon)
164
+ }
165
+
166
+ export function createMainWindow(id: string = randomUUID()) {
167
+ const state = windowState({
168
+ file: windowStateFile(id),
169
+ defaultWidth: 1280,
170
+ defaultHeight: 800,
171
+ })
172
+
173
+ const mode = tone()
174
+ const win = new BrowserWindow({
175
+ x: state.x,
176
+ y: state.y,
177
+ width: state.width,
178
+ height: state.height,
179
+ show: false,
180
+ autoHideMenuBar: true,
181
+ title: "OpenCode",
182
+ icon: iconPath(),
183
+ backgroundColor: backgroundColor ?? defaultBackgroundColor(),
184
+ ...(process.platform === "darwin"
185
+ ? {
186
+ titleBarStyle: "hidden" as const,
187
+ trafficLightPosition: { x: 14, y: 14 },
188
+ }
189
+ : {}),
190
+ ...(process.platform === "win32"
191
+ ? {
192
+ frame: false,
193
+ titleBarStyle: "hidden" as const,
194
+ titleBarOverlay: overlay({ mode }),
195
+ }
196
+ : {}),
197
+ webPreferences: {
198
+ preload: join(root, "../preload/index.js"),
199
+ contextIsolation: true,
200
+ nodeIntegration: false,
201
+ sandbox: true,
202
+ },
203
+ })
204
+
205
+ allowRendererPermissions(win)
206
+ wireWindowRecovery(win, id)
207
+
208
+ win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
209
+ const { requestHeaders } = details
210
+ upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
211
+ callback({ requestHeaders })
212
+ })
213
+
214
+ win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
215
+ const { responseHeaders = {} } = details
216
+ addRendererHeaders(details.url, responseHeaders)
217
+ callback({ responseHeaders })
218
+ })
219
+
220
+ state.manage(win)
221
+ registerWindow(win, id)
222
+ loadWindow(win, "index.html")
223
+ wireZoom(win)
224
+
225
+ win.once("ready-to-show", () => {
226
+ win.show()
227
+ })
228
+
229
+ return win
230
+ }
231
+
232
+ function registerWindow(win: BrowserWindow, id: string) {
233
+ windowIDs.set(win, id)
234
+ registry.register(id, win)
235
+
236
+ win.on("focus", () => registry.focused(id))
237
+ // Windows never emits before-quit on OS shutdown/logoff, but each window
238
+ // gets session-end before it closes; flag the quit so ids stay persisted.
239
+ win.on("session-end", () => registry.setQuitting())
240
+ win.on("closed", () => registry.closed(id))
241
+ }
242
+
243
+ function windowStateFile(id: string) {
244
+ return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
245
+ }
246
+
247
+ // Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names
248
+ // the per-window renderer store this window persists its tabs into.
249
+ function windowDataFile(id: string) {
250
+ return `neurocode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
251
+ }
252
+
253
+ export function registerRendererProtocol() {
254
+ if (protocol.isProtocolHandled(rendererProtocol)) return
255
+
256
+ protocol.handle(rendererProtocol, async (request) => {
257
+ const url = new URL(request.url)
258
+ if (url.host !== rendererHost) {
259
+ writeLog("protocol", "rejected host", { url: request.url }, "warn")
260
+ return new Response("Not found", { status: 404 })
261
+ }
262
+
263
+ const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
264
+ const rel = relative(rendererRoot, file)
265
+ if (rel.startsWith("..") || isAbsolute(rel)) {
266
+ writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
267
+ return new Response("Not found", { status: 404 })
268
+ }
269
+
270
+ try {
271
+ const range = request.headers.get("range")
272
+ const response = await net.fetch(pathToFileURL(file).toString(), {
273
+ headers: range ? { range } : undefined,
274
+ })
275
+ if (response.status >= 400) {
276
+ writeLog(
277
+ "protocol",
278
+ "fetch failed",
279
+ {
280
+ url: request.url,
281
+ file,
282
+ status: response.status,
283
+ statusText: response.statusText,
284
+ },
285
+ "error",
286
+ )
287
+ }
288
+ return addDocumentPolicy(response, file)
289
+ } catch (error) {
290
+ writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
291
+ return new Response("Not found", { status: 404 })
292
+ }
293
+ })
294
+ }
295
+
296
+ function loadWindow(win: BrowserWindow, html: string) {
297
+ const devUrl = process.env.ELECTRON_RENDERER_URL
298
+ if (devUrl) {
299
+ const url = new URL(html, devUrl)
300
+ void win.loadURL(url.toString())
301
+ return
302
+ }
303
+
304
+ void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
305
+ }
306
+
307
+ function wireWindowRecovery(win: BrowserWindow, name: string) {
308
+ let showing = false
309
+ const sampler = createUnresponsiveSampler(win, name)
310
+
311
+ const handle = async (button: string | undefined, wait: boolean) => {
312
+ if (button === "Export Logs") {
313
+ const sampling = sampler.stopAndFlush()
314
+ await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
315
+ if (wait && sampling) sampler.start()
316
+ return true
317
+ }
318
+ if (button === "Relaunch") {
319
+ sampler.stopAndFlush()
320
+ relaunchHandler()
321
+ return false
322
+ }
323
+ if (button === "Quit") {
324
+ sampler.stopAndFlush()
325
+ app.quit()
326
+ }
327
+ return false
328
+ }
329
+
330
+ const show = async (message: string, detail: string, wait: boolean) => {
331
+ if (showing || win.isDestroyed()) return
332
+ showing = true
333
+ try {
334
+ while (!win.isDestroyed()) {
335
+ const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
336
+ const result = await dialog.showMessageBox(win, {
337
+ type: "warning",
338
+ buttons,
339
+ defaultId: 0,
340
+ cancelId: 2,
341
+ message,
342
+ detail,
343
+ })
344
+ if (await handle(buttons[result.response], wait)) continue
345
+ return
346
+ }
347
+ } finally {
348
+ showing = false
349
+ }
350
+ }
351
+
352
+ const failed = (
353
+ event: string,
354
+ errorCode: number,
355
+ errorDescription: string,
356
+ validatedURL: string,
357
+ isMainFrame: boolean,
358
+ ) => {
359
+ writeLog(
360
+ "window",
361
+ "renderer load failed",
362
+ {
363
+ window: name,
364
+ event,
365
+ errorCode,
366
+ errorDescription,
367
+ validatedURL,
368
+ currentURL: safeWindowURL(win),
369
+ isMainFrame,
370
+ },
371
+ "error",
372
+ )
373
+
374
+ if (!isMainFrame || errorCode === -3) return
375
+ void show(
376
+ "OpenCode failed to load",
377
+ [`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
378
+ false,
379
+ )
380
+ }
381
+
382
+ win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
383
+ failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
384
+ })
385
+ win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
386
+ failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
387
+ })
388
+ win.webContents.on("render-process-gone", (_event, details) => {
389
+ sampler.stopAndFlush()
390
+ writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
391
+ void show(
392
+ "OpenCode window terminated unexpectedly",
393
+ [`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
394
+ false,
395
+ )
396
+ })
397
+ win.on("unresponsive", () => {
398
+ writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
399
+ sampler.start()
400
+ void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
401
+ })
402
+ win.on("responsive", () => {
403
+ writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
404
+ sampler.stopAndFlush()
405
+ })
406
+ win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
407
+ if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
408
+ writeLog("pty", "console", { window: name, level, message, line, sourceId })
409
+ }
410
+ })
411
+ win.webContents.on("preload-error", (_event, preloadPath, error) => {
412
+ writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
413
+ })
414
+ }
415
+
416
+ function addDocumentPolicy(response: Response, file: string) {
417
+ if (!file.toLowerCase().endsWith(".html")) return response
418
+ const headers = new Headers(response.headers)
419
+ headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
420
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
421
+ }
422
+
423
+ function allowRendererPermissions(win: BrowserWindow) {
424
+ const webContentsId = win.webContents.id
425
+
426
+ win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
427
+ callback(
428
+ rendererPermissions.has(permission) &&
429
+ isTrustedRendererUrl(details.requestingUrl) &&
430
+ webContents.id === webContentsId,
431
+ )
432
+ })
433
+ win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
434
+ if (!rendererPermissions.has(permission)) return false
435
+ if (webContents && webContents.id !== webContentsId) return false
436
+ return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
437
+ })
438
+ }
439
+
440
+ function isTrustedRendererUrl(value?: string) {
441
+ return isRendererUrl(value)
442
+ }
443
+
444
+ function addRendererHeaders(value: string, headers: Record<string, any>) {
445
+ upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
446
+ upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
447
+ if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
448
+ }
449
+
450
+ function isRendererUrl(value?: string, html = false) {
451
+ if (!value || !URL.canParse(value)) return false
452
+ const url = new URL(value)
453
+ if (html && !url.pathname.endsWith(".html")) return false
454
+ if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
455
+ const devUrl = process.env.ELECTRON_RENDERER_URL
456
+ if (!devUrl || !URL.canParse(devUrl)) return false
457
+ return url.origin === new URL(devUrl).origin
458
+ }
459
+
460
+ function wireZoom(win: BrowserWindow) {
461
+ pinchZoomEnabled.set(win, getPinchZoomEnabled())
462
+ win.webContents.setZoomFactor(1)
463
+ win.webContents.on("zoom-changed", (event, zoomDirection) => {
464
+ event.preventDefault()
465
+ if (pinchZoomEnabled.get(win)) {
466
+ win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
467
+ updateZoom(win)
468
+ return
469
+ }
470
+ if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
471
+ updateZoom(win)
472
+ })
473
+ }
474
+
475
+ function clampZoom(value: number) {
476
+ return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
477
+ }
478
+
479
+ function updateZoom(win: BrowserWindow) {
480
+ updateTitlebar(win)
481
+ win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
482
+ }
483
+
484
+ function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
485
+ const keyToChangeLower = keyToChange.toLowerCase()
486
+ for (const key of Object.keys(obj)) {
487
+ if (key.toLowerCase() === keyToChangeLower) {
488
+ // Reassign old key
489
+ obj[key] = value
490
+ // Done
491
+ return
492
+ }
493
+ }
494
+ // Insert at end instead
495
+ obj[keyToChange] = value
496
+ }
@@ -0,0 +1,103 @@
1
+ import { app, ipcMain } from "electron"
2
+ import type { IpcMainInvokeEvent } from "electron"
3
+ import type { WslServersController } from "./servers"
4
+ import { requireWslIpcString, requireWslIpcStrings } from "./policy"
5
+ import type { WslServersState } from "../../preload/types"
6
+
7
+ export function registerWslIpcHandlers(controller: WslServersController) {
8
+ if (process.platform !== "win32") {
9
+ registerUnavailableWslIpcHandlers()
10
+ return
11
+ }
12
+
13
+ const subscriptions = new Map<number, () => void>()
14
+ const unsubscribe = (id: number) => {
15
+ const off = subscriptions.get(id)
16
+ if (!off) return
17
+ off()
18
+ subscriptions.delete(id)
19
+ }
20
+
21
+ app.once("will-quit", () => {
22
+ subscriptions.forEach((off) => off())
23
+ subscriptions.clear()
24
+ })
25
+
26
+ ipcMain.handle("wsl-servers-subscribe", (event) => {
27
+ const id = event.sender.id
28
+ if (subscriptions.has(id)) return
29
+ subscriptions.set(
30
+ id,
31
+ controller.subscribe((payload) => {
32
+ if (event.sender.isDestroyed()) {
33
+ unsubscribe(id)
34
+ return
35
+ }
36
+ event.sender.send("wsl-servers-event", payload)
37
+ }),
38
+ )
39
+ event.sender.once("destroyed", () => unsubscribe(id))
40
+ })
41
+ ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id))
42
+ ipcMain.handle("wsl-servers-get-state", () => controller.getState())
43
+ ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime())
44
+ ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros())
45
+ ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl())
46
+ ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
47
+ controller.installDistro(requireWslIpcString("distro", name)),
48
+ )
49
+ ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) =>
50
+ controller.probeAddable(requireWslIpcStrings("distro", distros)),
51
+ )
52
+ ipcMain.handle("wsl-servers-install-neurocode", (_event: IpcMainInvokeEvent, name: string) =>
53
+ controller.installNeurocode(requireWslIpcString("distro", name)),
54
+ )
55
+ ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
56
+ controller.openTerminal(requireWslIpcString("distro", name)),
57
+ )
58
+ ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
59
+ controller.addServer(requireWslIpcString("distro", distro)),
60
+ )
61
+ ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
62
+ controller.removeServer(requireWslIpcString("server id", id)),
63
+ )
64
+ ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
65
+ controller.startServer(requireWslIpcString("server id", id)),
66
+ )
67
+ }
68
+
69
+ function registerUnavailableWslIpcHandlers() {
70
+ const unavailable = () => {
71
+ throw new Error("WSL is only available on Windows")
72
+ }
73
+ const state = (): WslServersState => ({
74
+ runtime: {
75
+ available: false,
76
+ version: null,
77
+ error: "WSL is only available on Windows",
78
+ },
79
+ installed: [],
80
+ online: [],
81
+ distroProbes: {},
82
+ neurocodeChecks: {},
83
+ pendingRestart: false,
84
+ servers: [],
85
+ job: null,
86
+ })
87
+
88
+ ipcMain.handle("wsl-servers-subscribe", (event) => {
89
+ event.sender.send("wsl-servers-event", { type: "state", state: state() })
90
+ })
91
+ ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
92
+ ipcMain.handle("wsl-servers-get-state", () => state())
93
+ ipcMain.handle("wsl-servers-probe-runtime", unavailable)
94
+ ipcMain.handle("wsl-servers-refresh-distros", unavailable)
95
+ ipcMain.handle("wsl-servers-install-wsl", unavailable)
96
+ ipcMain.handle("wsl-servers-install-distro", unavailable)
97
+ ipcMain.handle("wsl-servers-probe-addable", unavailable)
98
+ ipcMain.handle("wsl-servers-install-neurocode", unavailable)
99
+ ipcMain.handle("wsl-servers-open-terminal", unavailable)
100
+ ipcMain.handle("wsl-servers-add", unavailable)
101
+ ipcMain.handle("wsl-servers-remove", unavailable)
102
+ ipcMain.handle("wsl-servers-start", unavailable)
103
+ }
@@ -0,0 +1,33 @@
1
+ import type { WslDistroProbe, WslNeurocodeCheck, WslServerItem } from "../../preload/types"
2
+
3
+ export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
4
+ return servers.find((item) => item.config.distro === distro)?.config.id
5
+ }
6
+
7
+ export function clearWslDistroState(
8
+ distroProbes: Record<string, WslDistroProbe>,
9
+ neurocodeChecks: Record<string, WslNeurocodeCheck>,
10
+ distro: string,
11
+ ) {
12
+ const nextDistroProbes = { ...distroProbes }
13
+ const nextNeurocodeChecks = { ...neurocodeChecks }
14
+ delete nextDistroProbes[distro]
15
+ delete nextNeurocodeChecks[distro]
16
+ return { distroProbes: nextDistroProbes, neurocodeChecks: nextNeurocodeChecks }
17
+ }
18
+
19
+ export function wslTerminalArgs(distro?: string | null) {
20
+ return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
21
+ }
22
+
23
+ export function requireWslIpcString(name: string, value: unknown) {
24
+ if (typeof value === "string" && value.length > 0) return value
25
+ throw new Error(`Invalid ${name}`)
26
+ }
27
+
28
+ export function requireWslIpcStrings(name: string, value: unknown) {
29
+ if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
30
+ const values = value.map((item) => requireWslIpcString(name, item))
31
+ if (values.length > 0) return values
32
+ throw new Error(`Invalid ${name}`)
33
+ }