@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,94 @@
1
+ import { readdir, readFile, rm, stat } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+
4
+ const EMPTY_STORE_MAX_BYTES = 128
5
+ const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
6
+ const DRAFT_KEEP_RECENT = 100
7
+
8
+ type StoreKind = "draft" | "workspace"
9
+ type StoreCandidate = {
10
+ name: string
11
+ path: string
12
+ kind: StoreKind
13
+ modified: number
14
+ empty: boolean
15
+ }
16
+
17
+ export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
18
+ const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
19
+ const candidates = (
20
+ await Promise.all(
21
+ entries
22
+ .filter((entry) => entry.isFile())
23
+ .map(async (entry) => {
24
+ const kind = storeKind(entry.name)
25
+ if (!kind) return
26
+
27
+ const file = join(userDataPath, entry.name)
28
+ const stats = await stat(file).catch(() => undefined)
29
+ if (!stats?.isFile()) return
30
+
31
+ return {
32
+ name: entry.name,
33
+ path: file,
34
+ kind,
35
+ modified: stats.mtimeMs,
36
+ empty: await isEmptyStore(file, stats.size),
37
+ }
38
+ }),
39
+ )
40
+ ).filter((candidate) => !!candidate)
41
+
42
+ const stale = new Set<StoreCandidate>()
43
+ for (const candidate of candidates) {
44
+ if (candidate.empty) stale.add(candidate)
45
+ if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
46
+ }
47
+
48
+ candidates
49
+ .filter((candidate) => candidate.kind === "draft" && !candidate.empty)
50
+ .sort((a, b) => b.modified - a.modified)
51
+ .slice(DRAFT_KEEP_RECENT)
52
+ .forEach((candidate) => stale.add(candidate))
53
+
54
+ const deleted = await Promise.all(
55
+ [...stale].map(async (candidate) => {
56
+ await rm(candidate.path, { force: true })
57
+ return candidate.name
58
+ }),
59
+ )
60
+
61
+ return { scanned: candidates.length, deleted }
62
+ }
63
+
64
+ export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
65
+ if (!storeKind(name)) return false
66
+
67
+ const file = join(userDataPath, name)
68
+ const stats = await stat(file).catch(() => undefined)
69
+ if (!stats?.isFile()) return false
70
+ if (!(await isEmptyStore(file, stats.size))) return false
71
+
72
+ await rm(file, { force: true })
73
+ return true
74
+ }
75
+
76
+ function storeKind(name: string): StoreKind | undefined {
77
+ if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
78
+ if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
79
+ }
80
+
81
+ async function isEmptyStore(file: string, size: number) {
82
+ if (size > EMPTY_STORE_MAX_BYTES) return false
83
+
84
+ const raw = await readFile(file, "utf8").catch(() => undefined)
85
+ if (raw === undefined) return false
86
+ if (raw.trim() === "") return true
87
+
88
+ try {
89
+ const parsed = JSON.parse(raw) as unknown
90
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
91
+ } catch {
92
+ return false
93
+ }
94
+ }
@@ -0,0 +1,7 @@
1
+ export const SETTINGS_STORE = "neurocode.settings"
2
+ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
3
+ export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
4
+ export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible"
5
+ export const WSL_SERVERS_KEY = "wslServers"
6
+ export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
7
+ export const WINDOW_IDS_KEY = "windowIds"
@@ -0,0 +1,35 @@
1
+ import Store from "electron-store"
2
+ import electron from "electron"
3
+ import { rmSync } from "node:fs"
4
+ import { join } from "node:path"
5
+
6
+ import { SETTINGS_STORE } from "./store-keys"
7
+ import { deleteStoreFileIfEmpty } from "./store-cleanup"
8
+
9
+ const cache = new Map<string, Store>()
10
+
11
+ // We cannot instantiate the electron-store at module load time because
12
+ // module import hoisting causes this to run before app.setPath("userData", ...)
13
+ // in index.ts has executed, which would result in files being written to the default directory
14
+ // (e.g. bad: %APPDATA%\@opencode-ai\desktop\neurocode.settings vs good: %APPDATA%\ai.neurocode.desktop.dev\neurocode.settings).
15
+ export function getStore(name = SETTINGS_STORE) {
16
+ const cached = cache.get(name)
17
+ if (cached) return cached
18
+ const next = new Store({
19
+ name,
20
+ cwd: electron.app.getPath("userData"),
21
+ fileExtension: "",
22
+ accessPropertiesByDotNotation: false,
23
+ })
24
+ cache.set(name, next)
25
+ return next
26
+ }
27
+
28
+ export async function removeStoreFileIfEmpty(name: string) {
29
+ if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
30
+ }
31
+
32
+ export function removeStoreFile(name: string) {
33
+ rmSync(join(electron.app.getPath("userData"), name), { force: true })
34
+ cache.delete(name)
35
+ }
@@ -0,0 +1,70 @@
1
+ import type { BrowserWindow } from "electron"
2
+ import { write as writeLog } from "./logging"
3
+ import { safeWindowURL } from "./window-state"
4
+
5
+ const sampleInterval = 1000
6
+ const samplePeriod = 15000
7
+
8
+ export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
9
+ let sampleTimer: ReturnType<typeof setTimeout> | undefined
10
+ let stopTimer: ReturnType<typeof setTimeout> | undefined
11
+ let sampling = false
12
+ const samples = new Map<string, number>()
13
+
14
+ const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
15
+ const clearTimers = () => {
16
+ if (sampleTimer) clearTimeout(sampleTimer)
17
+ if (stopTimer) clearTimeout(stopTimer)
18
+ sampleTimer = undefined
19
+ stopTimer = undefined
20
+ }
21
+
22
+ const schedule = () => {
23
+ sampleTimer = setTimeout(() => {
24
+ void collect()
25
+ }, sampleInterval)
26
+ }
27
+
28
+ const collect = async () => {
29
+ if (!active()) return
30
+ const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
31
+ writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
32
+ return undefined
33
+ })
34
+ if (!active()) return
35
+ if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
36
+ schedule()
37
+ }
38
+
39
+ const stopAndFlush = () => {
40
+ const wasSampling = sampling
41
+ sampling = false
42
+ clearTimers()
43
+ if (samples.size === 0) return wasSampling
44
+
45
+ const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
46
+ const total = entries.reduce((sum, entry) => sum + entry[1], 0)
47
+ const message = [
48
+ "renderer unresponsive samples",
49
+ `Window: ${name}`,
50
+ `URL: ${safeWindowURL(win)}`,
51
+ ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
52
+ `Total Samples: ${total}`,
53
+ ].join("\n")
54
+ writeLog("window", message, undefined, "error")
55
+ samples.clear()
56
+ return wasSampling
57
+ }
58
+
59
+ const start = () => {
60
+ if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
61
+ sampling = true
62
+ samples.clear()
63
+ schedule()
64
+ stopTimer = setTimeout(stopAndFlush, samplePeriod)
65
+ }
66
+
67
+ win.on("closed", stopAndFlush)
68
+
69
+ return { start, stopAndFlush }
70
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
3
+
4
+ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
5
+ const calls: string[] = []
6
+ const backend: UpdaterBackend = {
7
+ async checkForUpdates() {
8
+ calls.push("check")
9
+ return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
10
+ },
11
+ async downloadUpdate() {
12
+ calls.push("download")
13
+ },
14
+ quitAndInstall() {
15
+ calls.push("install")
16
+ },
17
+ }
18
+ let ready = input?.ready
19
+ const controller = createUpdaterController({
20
+ enabled: true,
21
+ currentVersion: input?.currentVersion ?? "1.0.0",
22
+ backend,
23
+ persistence: {
24
+ get: () => ready,
25
+ set: (value) => {
26
+ ready = value
27
+ },
28
+ clear: () => {
29
+ ready = undefined
30
+ },
31
+ },
32
+ stop: async () => {
33
+ calls.push("stop")
34
+ },
35
+ })
36
+ return { controller, calls, getReady: () => ready }
37
+ }
38
+
39
+ describe("updater controller", () => {
40
+ test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
41
+ const app = setup()
42
+ const states: ReturnType<typeof app.controller.getState>[] = []
43
+ app.controller.subscribe((state) => states.push(state))
44
+
45
+ await app.controller.start()
46
+
47
+ expect(app.calls).toEqual(["check", "download"])
48
+ expect(app.getReady()).toEqual({ version: "2.0.0" })
49
+ expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
50
+ expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
51
+ })
52
+
53
+ test("revalidates a persisted target through the updater cache on launch", async () => {
54
+ const app = setup({ ready: { version: "2.0.0" } })
55
+
56
+ await app.controller.start()
57
+
58
+ expect(app.calls).toEqual(["check", "download"])
59
+ expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
60
+ })
61
+
62
+ test("clears a target already installed before checking", async () => {
63
+ const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
64
+
65
+ await app.controller.start()
66
+
67
+ expect(app.getReady()).toBeUndefined()
68
+ expect(app.calls).toEqual(["check"])
69
+ })
70
+
71
+ test("coalesces concurrent checks", async () => {
72
+ const app = setup()
73
+
74
+ await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
75
+
76
+ expect(app.calls).toEqual(["check", "download"])
77
+ })
78
+
79
+ test("returns to ready when quitAndInstall returns without exiting", async () => {
80
+ const app = setup()
81
+ await app.controller.start()
82
+
83
+ await app.controller.install()
84
+
85
+ expect(app.calls).toEqual(["check", "download", "stop", "install"])
86
+ expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
87
+ })
88
+
89
+ test("returns to ready when installation cannot start", async () => {
90
+ const app = setup()
91
+ await app.controller.start()
92
+
93
+ const failed = createUpdaterController({
94
+ enabled: true,
95
+ currentVersion: "1.0.0",
96
+ backend: {
97
+ checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
98
+ downloadUpdate: async () => {},
99
+ quitAndInstall() {},
100
+ },
101
+ persistence: { get: () => undefined, set() {}, clear() {} },
102
+ stop: async () => {
103
+ throw new Error("stop failed")
104
+ },
105
+ })
106
+ await failed.start()
107
+
108
+ await expect(failed.install()).rejects.toThrow("stop failed")
109
+ expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
110
+ })
111
+ })
@@ -0,0 +1,97 @@
1
+ import type { UpdaterState } from "@neurocode-ai/app/updater"
2
+
3
+ export type { UpdaterState } from "@neurocode-ai/app/updater"
4
+
5
+ export type UpdaterReadyRecord = { version: string }
6
+
7
+ export type UpdaterBackend = {
8
+ checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
9
+ downloadUpdate(): Promise<unknown>
10
+ quitAndInstall(): void
11
+ }
12
+
13
+ type UpdaterPersistence = {
14
+ get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
15
+ set(value: UpdaterReadyRecord): void | Promise<void>
16
+ clear(): void | Promise<void>
17
+ }
18
+
19
+ export function createUpdaterController(input: {
20
+ enabled: boolean
21
+ currentVersion: string
22
+ backend: UpdaterBackend
23
+ persistence: UpdaterPersistence
24
+ stop: () => Promise<void>
25
+ log?: (message: string, data?: object) => void
26
+ }) {
27
+ let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
28
+ let pending: Promise<UpdaterState> | undefined
29
+ const listeners = new Set<(state: UpdaterState) => void>()
30
+
31
+ const transition = (next: UpdaterState) => {
32
+ input.log?.("updater state changed", { from: state.status, to: next.status })
33
+ state = next
34
+ listeners.forEach((listener) => listener(state))
35
+ return state
36
+ }
37
+
38
+ const check = () => {
39
+ if (!input.enabled) return Promise.resolve(state)
40
+ if (state.status === "ready") return Promise.resolve(state)
41
+ if (pending) return pending
42
+
43
+ pending = (async () => {
44
+ transition({ status: "checking" })
45
+ const result = await input.backend.checkForUpdates()
46
+ const version = result?.updateInfo?.version
47
+ if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
48
+ await input.persistence.clear()
49
+ return transition({ status: "up-to-date" })
50
+ }
51
+
52
+ transition({ status: "downloading", version })
53
+ await input.backend.downloadUpdate()
54
+ await input.persistence.set({ version })
55
+ return transition({ status: "ready", version })
56
+ })()
57
+ .catch((error) =>
58
+ transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
59
+ )
60
+ .finally(() => {
61
+ pending = undefined
62
+ })
63
+ return pending
64
+ }
65
+
66
+ return {
67
+ getState: () => state,
68
+ subscribe(listener: (state: UpdaterState) => void) {
69
+ listeners.add(listener)
70
+ listener(state)
71
+ return () => listeners.delete(listener)
72
+ },
73
+ async start() {
74
+ const ready = await input.persistence.get()
75
+ if (ready?.version === input.currentVersion) await input.persistence.clear()
76
+ return check()
77
+ },
78
+ check,
79
+ async install() {
80
+ if (state.status !== "ready") throw new Error("Update is not ready to install")
81
+ const version = state.version
82
+ transition({ status: "installing", version })
83
+ await input
84
+ .stop()
85
+ .then(() => {
86
+ input.backend.quitAndInstall()
87
+ transition({ status: "ready", version })
88
+ })
89
+ .catch((error) => {
90
+ transition({ status: "ready", version })
91
+ throw error
92
+ })
93
+ },
94
+ }
95
+ }
96
+
97
+ export type UpdaterController = ReturnType<typeof createUpdaterController>
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createUpdaterSubscriptions } from "./updater-subscriptions"
3
+
4
+ describe("updater subscriptions", () => {
5
+ test("replaces the previous renderer subscription on reload", () => {
6
+ const subscriptions = createUpdaterSubscriptions()
7
+ const disposed: string[] = []
8
+
9
+ subscriptions.set(1, () => disposed.push("first"))
10
+ subscriptions.set(1, () => disposed.push("second"))
11
+
12
+ expect(disposed).toEqual(["first"])
13
+ subscriptions.delete(1)
14
+ expect(disposed).toEqual(["first", "second"])
15
+ })
16
+ })
@@ -0,0 +1,20 @@
1
+ export function createUpdaterSubscriptions() {
2
+ const subscriptions = new Map<number, () => void>()
3
+
4
+ const remove = (id: number) => {
5
+ subscriptions.get(id)?.()
6
+ subscriptions.delete(id)
7
+ }
8
+
9
+ return {
10
+ set(id: number, unsubscribe: () => void) {
11
+ remove(id)
12
+ subscriptions.set(id, unsubscribe)
13
+ },
14
+ delete: remove,
15
+ clear() {
16
+ subscriptions.forEach((unsubscribe) => unsubscribe())
17
+ subscriptions.clear()
18
+ },
19
+ }
20
+ }
@@ -0,0 +1,85 @@
1
+ import { app, dialog } from "electron"
2
+ import pkg from "electron-updater"
3
+ import { UPDATER_ENABLED } from "./constants"
4
+ import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
5
+ import { getLogger } from "./logging"
6
+ import { getStore } from "./store"
7
+ import { setAppQuitting } from "./windows"
8
+
9
+ const { autoUpdater } = pkg
10
+ const key = "ready"
11
+
12
+ export function setupAutoUpdater(stop: () => Promise<void>) {
13
+ const logger = getLogger()
14
+ autoUpdater.logger = logger
15
+ autoUpdater.channel = "latest"
16
+ autoUpdater.allowPrerelease = false
17
+ autoUpdater.allowDowngrade = true
18
+ autoUpdater.autoDownload = false
19
+ autoUpdater.autoInstallOnAppQuit = false
20
+ logger.log("auto updater configured", {
21
+ channel: autoUpdater.channel,
22
+ allowPrerelease: autoUpdater.allowPrerelease,
23
+ allowDowngrade: autoUpdater.allowDowngrade,
24
+ currentVersion: app.getVersion(),
25
+ })
26
+
27
+ const store = getStore("neurocode.updater")
28
+ return createUpdaterController({
29
+ enabled: UPDATER_ENABLED,
30
+ currentVersion: app.getVersion(),
31
+ backend: {
32
+ checkForUpdates: () => autoUpdater.checkForUpdates(),
33
+ downloadUpdate: () => autoUpdater.downloadUpdate(),
34
+ quitAndInstall: () => {
35
+ // quitAndInstall closes all windows before emitting before-quit, so
36
+ // flag the quit first to keep window ids persisted for restore.
37
+ setAppQuitting()
38
+ try {
39
+ autoUpdater.quitAndInstall()
40
+ } catch (error) {
41
+ // The install failed and the app keeps running; clear the flag so
42
+ // deliberate window closes prune ids again.
43
+ setAppQuitting(false)
44
+ throw error
45
+ }
46
+ },
47
+ },
48
+ persistence: {
49
+ get() {
50
+ const value = store.get(key)
51
+ if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
52
+ return { version: value.version } satisfies UpdaterReadyRecord
53
+ },
54
+ set: (value) => store.set(key, value),
55
+ clear: () => store.delete(key),
56
+ },
57
+ stop,
58
+ log: (message, data) => logger.log(message, data),
59
+ })
60
+ }
61
+
62
+ export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
63
+ const state = await controller.check()
64
+ if (state.status === "error") {
65
+ if (!alertOnFail) return
66
+ await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
67
+ return
68
+ }
69
+ if (state.status === "up-to-date") {
70
+ if (!alertOnFail) return
71
+ await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
72
+ return
73
+ }
74
+ if (state.status !== "ready") return
75
+
76
+ const response = await dialog.showMessageBox({
77
+ type: "info",
78
+ message: `Update ${state.version} downloaded. Restart now?`,
79
+ title: "Update Ready",
80
+ buttons: ["Restart", "Later"],
81
+ defaultId: 0,
82
+ cancelId: 1,
83
+ })
84
+ if (response.response === 0) await controller.install()
85
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createWindowRegistry } from "./window-registry"
3
+
4
+ function setup(initial: unknown = []) {
5
+ const state = { stored: initial }
6
+ const cleaned: string[] = []
7
+ const registry = createWindowRegistry<{ name: string }>({
8
+ read: () => state.stored,
9
+ write: (ids) => {
10
+ state.stored = ids
11
+ },
12
+ cleanup: (id) => cleaned.push(id),
13
+ })
14
+ return { registry, state, cleaned }
15
+ }
16
+
17
+ describe("window registry", () => {
18
+ test("restores persisted ids and ignores malformed entries", () => {
19
+ expect(setup(["a", "", 42, "b"]).registry.persisted()).toEqual(["a", "b"])
20
+ expect(setup("junk").registry.persisted()).toEqual([])
21
+ expect(setup(undefined).registry.persisted()).toEqual([])
22
+ })
23
+
24
+ test("registers windows and persists each id once", () => {
25
+ const app = setup()
26
+ app.registry.register("a", { name: "a" })
27
+ app.registry.register("a", { name: "a" })
28
+ app.registry.register("b", { name: "b" })
29
+ expect(app.state.stored).toEqual(["a", "b"])
30
+ })
31
+
32
+ test("forgets a deliberately closed window while others remain open", () => {
33
+ const app = setup()
34
+ app.registry.register("a", { name: "a" })
35
+ app.registry.register("b", { name: "b" })
36
+ app.registry.closed("a")
37
+ expect(app.state.stored).toEqual(["b"])
38
+ expect(app.cleaned).toEqual(["a"])
39
+ })
40
+
41
+ test("keeps the id when the last window closes so relaunch restores it", () => {
42
+ const app = setup()
43
+ app.registry.register("a", { name: "a" })
44
+ app.registry.closed("a")
45
+ expect(app.state.stored).toEqual(["a"])
46
+ expect(app.cleaned).toEqual([])
47
+
48
+ const restarted = createWindowRegistry<{ name: string }>({
49
+ read: () => app.state.stored,
50
+ write: (ids) => {
51
+ app.state.stored = ids
52
+ },
53
+ cleanup: () => {},
54
+ })
55
+ expect(restarted.persisted()).toEqual(["a"])
56
+ })
57
+
58
+ test("keeps every id when windows close during quit", () => {
59
+ const app = setup()
60
+ app.registry.register("a", { name: "a" })
61
+ app.registry.register("b", { name: "b" })
62
+ app.registry.setQuitting()
63
+ app.registry.closed("a")
64
+ app.registry.closed("b")
65
+ expect(app.state.stored).toEqual(["a", "b"])
66
+ expect(app.cleaned).toEqual([])
67
+ })
68
+
69
+ test("tracks the last focused window and falls back on close", () => {
70
+ const app = setup()
71
+ app.registry.register("a", { name: "a" })
72
+ app.registry.register("b", { name: "b" })
73
+ app.registry.focused("a")
74
+ expect(app.registry.lastFocused()).toEqual({ name: "a" })
75
+ app.registry.closed("a")
76
+ expect(app.registry.lastFocused()).toEqual({ name: "b" })
77
+ app.registry.closed("b")
78
+ expect(app.registry.lastFocused()).toBeUndefined()
79
+ })
80
+
81
+ test("resumes forgetting closed windows after the quit flag resets", () => {
82
+ const app = setup()
83
+ app.registry.register("a", { name: "a" })
84
+ app.registry.register("b", { name: "b" })
85
+ app.registry.setQuitting()
86
+ app.registry.setQuitting(false)
87
+ app.registry.closed("a")
88
+ expect(app.state.stored).toEqual(["b"])
89
+ expect(app.cleaned).toEqual(["a"])
90
+ })
91
+ })
@@ -0,0 +1,47 @@
1
+ // Tracks open windows and the persisted window id list used to restore
2
+ // windows (and their per-window persisted state) across app launches.
3
+ export function createWindowRegistry<W>(persistence: {
4
+ read: () => unknown
5
+ write: (ids: string[]) => void
6
+ cleanup: (id: string) => void
7
+ }) {
8
+ const windows = new Map<string, W>()
9
+ let quitting = false
10
+ let lastFocusedID: string | undefined
11
+
12
+ const persisted = () => {
13
+ const value = persistence.read()
14
+ if (!Array.isArray(value)) return []
15
+ return value.filter((id): id is string => typeof id === "string" && id.length > 0)
16
+ }
17
+
18
+ return {
19
+ persisted,
20
+ setQuitting(value = true) {
21
+ quitting = value
22
+ },
23
+ register(id: string, window: W) {
24
+ windows.set(id, window)
25
+ const ids = persisted()
26
+ if (!ids.includes(id)) persistence.write([...ids, id])
27
+ },
28
+ focused(id: string) {
29
+ lastFocusedID = id
30
+ },
31
+ lastFocused() {
32
+ if (!lastFocusedID) return
33
+ return windows.get(lastFocusedID)
34
+ },
35
+ closed(id: string) {
36
+ windows.delete(id)
37
+ if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
38
+ // Only a deliberate close (app keeps running with other windows open)
39
+ // forgets a window. Closing the last window quits the app and fires
40
+ // `closed` before `before-quit`, so treat it as a quit and keep the id
41
+ // for restore on next launch.
42
+ if (quitting || windows.size === 0) return
43
+ persistence.write(persisted().filter((item) => item !== id))
44
+ persistence.cleanup(id)
45
+ },
46
+ }
47
+ }