@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,521 @@
1
+ import type {
2
+ WslDistroProbe,
3
+ WslInstalledDistro,
4
+ WslJob,
5
+ WslOnlineDistro,
6
+ WslNeurocodeCheck,
7
+ WslRuntimeCheck,
8
+ WslServerConfig,
9
+ WslServerItem,
10
+ WslServerRuntime,
11
+ WslServersEvent,
12
+ WslServersState,
13
+ } from "../../preload/types"
14
+ import { WSL_SERVERS_KEY } from "../store-keys"
15
+ import { getStore } from "../store"
16
+ import { expectNeurocodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
17
+ import { clearWslDistroState, wslServerIdToRestart } from "./policy"
18
+ import {
19
+ installWslDistro,
20
+ installWslNeurocode,
21
+ installWslRuntimeElevated,
22
+ listInstalledWslDistros,
23
+ listOnlineWslDistros,
24
+ openWslTerminal,
25
+ probeWslDistro,
26
+ probeWslRuntime,
27
+ readWslCommandVersion,
28
+ resolveWslNeurocode,
29
+ summarize,
30
+ } from "./runtime"
31
+
32
+ type RunningSidecar = {
33
+ listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
34
+ url: string
35
+ username: string | null
36
+ password: string
37
+ }
38
+
39
+ type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
40
+
41
+ type ControllerLogger = {
42
+ log: (message: string, meta?: unknown) => void
43
+ error: (message: string, meta?: unknown) => void
44
+ }
45
+
46
+ type WslServersControllerOptions = {
47
+ logger?: ControllerLogger
48
+ readServers?: () => WslServerConfig[]
49
+ writeServers?: (servers: WslServerConfig[]) => void
50
+ probeDistro?: typeof probeWslDistro
51
+ resolveNeurocode?: typeof resolveWslNeurocode
52
+ readCommandVersion?: typeof readWslCommandVersion
53
+ }
54
+
55
+ export type WslServersController = ReturnType<typeof createWslServersController>
56
+
57
+ export function wslServerIdForDistro(distro: string) {
58
+ return `wsl:${distro}`
59
+ }
60
+
61
+ export function createWslServersController(
62
+ appVersion: string,
63
+ spawnSidecar: SpawnSidecar,
64
+ options?: WslServersControllerOptions,
65
+ ) {
66
+ let state: WslServersState = initialState()
67
+ const listeners = new Set<(event: WslServersEvent) => void>()
68
+ const sidecars = new Map<string, RunningSidecar>()
69
+ const startAttempts = new Map<string, number>()
70
+ let jobAbort: AbortController | undefined
71
+ const logger = options?.logger
72
+ const readServers = options?.readServers ?? readPersistedServers
73
+ const writeServers = options?.writeServers ?? writePersistedServers
74
+ const probeDistro = options?.probeDistro ?? probeWslDistro
75
+
76
+ const emit = () => {
77
+ for (const listener of listeners) listener({ type: "state", state })
78
+ }
79
+
80
+ const setState = (next: Partial<WslServersState>) => {
81
+ state = { ...state, ...next }
82
+ emit()
83
+ }
84
+
85
+ const persistServers = (servers: WslServerConfig[]) => {
86
+ writeServers(servers)
87
+ }
88
+
89
+ const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
90
+ const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
91
+ setState({ servers: next })
92
+ }
93
+
94
+ const beginJob = (job: WslJob): AbortController => {
95
+ jobAbort?.abort()
96
+ const abort = new AbortController()
97
+ jobAbort = abort
98
+ setState({ job })
99
+ return abort
100
+ }
101
+
102
+ const endJob = (abort: AbortController) => {
103
+ if (jobAbort !== abort) return
104
+ jobAbort = undefined
105
+ setState({ job: null })
106
+ }
107
+
108
+ const refreshFromStore = () => {
109
+ const persisted = readServers()
110
+ const items: WslServerItem[] = persisted.map((config) => {
111
+ const existing = state.servers.find((item) => item.config.id === config.id)
112
+ return {
113
+ config,
114
+ runtime: existing?.runtime ?? { kind: "stopped" },
115
+ }
116
+ })
117
+ setState({ servers: items })
118
+ }
119
+
120
+ const setRuntime = (id: string, runtime: WslServerRuntime) => {
121
+ updateServer(id, (item) => ({ ...item, runtime }))
122
+ }
123
+
124
+ const setNeurocodeCheck = (distro: string, check: WslNeurocodeCheck) => {
125
+ setState({
126
+ neurocodeChecks: {
127
+ ...state.neurocodeChecks,
128
+ [distro]: check,
129
+ },
130
+ })
131
+ }
132
+
133
+ const checkNeurocode = async (distro: string, opts?: { signal?: AbortSignal }) => {
134
+ const resolved = await (options?.resolveNeurocode ?? resolveWslNeurocode)(distro, opts)
135
+ const version = resolved
136
+ ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
137
+ : null
138
+ return neurocodeCheck(distro, resolved, version, appVersion)
139
+ }
140
+
141
+ const refreshNeurocodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
142
+ setNeurocodeCheck(distro, await checkNeurocode(distro, opts))
143
+ }
144
+
145
+ const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
146
+ const unique = [...new Set(distros)]
147
+ const distroProbes = await Promise.all(
148
+ unique
149
+ .filter((distro) => !state.distroProbes[distro])
150
+ .map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
151
+ )
152
+ if (distroProbes.length) {
153
+ setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
154
+ }
155
+
156
+ const neurocodeChecks = await Promise.all(
157
+ unique
158
+ .filter((distro) => distroProbeReady(state.distroProbes[distro]))
159
+ .filter((distro) => !state.neurocodeChecks[distro])
160
+ .map(async (distro) => [distro, await checkNeurocode(distro, opts)] as const),
161
+ )
162
+ if (neurocodeChecks.length) {
163
+ setState({ neurocodeChecks: { ...state.neurocodeChecks, ...Object.fromEntries(neurocodeChecks) } })
164
+ }
165
+ }
166
+
167
+ const hasServer = (id: string, distro: string) => {
168
+ return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
169
+ }
170
+
171
+ const refreshNeurocodeCheckBackground = (id: string, distro: string) => {
172
+ void checkNeurocode(distro)
173
+ .then((check) => {
174
+ if (!hasServer(id, distro)) return
175
+ setNeurocodeCheck(distro, check)
176
+ })
177
+ .catch((error) => {
178
+ const message = error instanceof Error ? error.message : String(error)
179
+ logger?.error("wsl neurocode check failed", { id, distro, message })
180
+ })
181
+ }
182
+
183
+ const refreshNeurocodeChecks = async () => {
184
+ await Promise.all(
185
+ state.servers.map((item) =>
186
+ checkNeurocode(item.config.distro)
187
+ .then((check) => {
188
+ if (!hasServer(item.config.id, item.config.distro)) return
189
+ setNeurocodeCheck(item.config.distro, check)
190
+ })
191
+ .catch((error) => {
192
+ const message = error instanceof Error ? error.message : String(error)
193
+ logger?.error("wsl neurocode check failed", {
194
+ id: item.config.id,
195
+ distro: item.config.distro,
196
+ message,
197
+ })
198
+ }),
199
+ ),
200
+ )
201
+ }
202
+
203
+ const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
204
+ const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
205
+ return { installed, online }
206
+ }
207
+
208
+ const nextStartAttempt = (id: string) => {
209
+ const next = (startAttempts.get(id) ?? 0) + 1
210
+ startAttempts.set(id, next)
211
+ return next
212
+ }
213
+
214
+ const invalidateStartAttempt = (id: string) => {
215
+ startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
216
+ }
217
+
218
+ const isCurrentStartAttempt = (id: string, attempt: number) => {
219
+ return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
220
+ }
221
+
222
+ const startServer = async (id: string) => {
223
+ const item = state.servers.find((x) => x.config.id === id)
224
+ if (!item) return
225
+ const attempt = nextStartAttempt(id)
226
+ await stopServerInternal(id)
227
+ if (!isCurrentStartAttempt(id, attempt)) return
228
+ setRuntime(id, { kind: "starting" })
229
+ logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
230
+ try {
231
+ const sidecar = await spawnSidecar(item.config.distro)
232
+ if (!isCurrentStartAttempt(id, attempt)) {
233
+ try {
234
+ sidecar.listener.stop()
235
+ } catch {
236
+ // ignore stop errors for stale sidecars
237
+ }
238
+ return
239
+ }
240
+ sidecars.set(id, sidecar)
241
+ setRuntime(id, {
242
+ kind: "ready",
243
+ url: sidecar.url,
244
+ username: sidecar.username,
245
+ password: sidecar.password,
246
+ })
247
+ sidecar.listener.onExit((code, signal) => {
248
+ if (sidecars.get(id) !== sidecar) return
249
+ sidecars.delete(id)
250
+ const message = startupFailure(code, signal)
251
+ setRuntime(id, { kind: "failed", message })
252
+ logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
253
+ })
254
+ refreshNeurocodeCheckBackground(id, item.config.distro)
255
+ logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error)
258
+ if (!isCurrentStartAttempt(id, attempt)) return
259
+ setRuntime(id, { kind: "failed", message })
260
+ // Without this, an Ubuntu-style silent failure leaves no trace in
261
+ // main.log — the controller captures the message in its state but
262
+ // nothing surfaces unless the user opens the WSL servers dialog.
263
+ logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
264
+ }
265
+ }
266
+
267
+ const stopServerInternal = async (id: string) => {
268
+ const existing = sidecars.get(id)
269
+ if (!existing) return
270
+ sidecars.delete(id)
271
+ try {
272
+ existing.listener.stop()
273
+ } catch {
274
+ // ignore stop errors
275
+ }
276
+ }
277
+
278
+ const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
279
+ const abort = beginJob(job)
280
+ try {
281
+ const value = await runner(abort)
282
+ endJob(abort)
283
+ return value
284
+ } catch (error) {
285
+ if (error instanceof Error && error.name === "AbortError") {
286
+ endJob(abort)
287
+ return undefined
288
+ }
289
+ const err = error instanceof Error ? error : new Error(String(error))
290
+ endJob(abort)
291
+ throw err
292
+ }
293
+ }
294
+
295
+ return {
296
+ getState() {
297
+ return state
298
+ },
299
+ subscribe(listener: (event: WslServersEvent) => void) {
300
+ listeners.add(listener)
301
+ return () => listeners.delete(listener)
302
+ },
303
+
304
+ async initialize() {
305
+ refreshFromStore()
306
+ void refreshNeurocodeChecks()
307
+ for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
308
+ },
309
+
310
+ async probeRuntime() {
311
+ await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
312
+ const runtime = await probeWslRuntime({ signal: abort.signal })
313
+ setState({
314
+ runtime,
315
+ pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
316
+ })
317
+ })
318
+ },
319
+
320
+ async refreshDistros() {
321
+ await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
322
+ setState(await refreshDistroLists({ signal: abort.signal }))
323
+ })
324
+ },
325
+
326
+ async installWsl() {
327
+ await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
328
+ const result = await installWslRuntimeElevated({ signal: abort.signal })
329
+ if (result.code !== 0) {
330
+ const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
331
+ throw new Error(message)
332
+ }
333
+ const runtime = await probeWslRuntime({ signal: abort.signal })
334
+ setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
335
+ })
336
+ },
337
+
338
+ async installDistro(name: string) {
339
+ await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
340
+ const result = await installWslDistro(name, { signal: abort.signal })
341
+ if (result.code !== 0) {
342
+ const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
343
+ throw new Error(message)
344
+ }
345
+ const distros = await refreshDistroLists({ signal: abort.signal })
346
+ const probe = await probeDistro(name, { signal: abort.signal })
347
+ setState({
348
+ ...distros,
349
+ distroProbes: { ...state.distroProbes, [name]: probe },
350
+ })
351
+ })
352
+ },
353
+
354
+ async probeAddable(distros: string[]) {
355
+ if (!distros.length) return
356
+ await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
357
+ await probeAddableDistros(distros, { signal: abort.signal })
358
+ })
359
+ },
360
+
361
+ async installNeurocode(name: string) {
362
+ await runJob({ kind: "install-neurocode", distro: name, startedAt: Date.now() }, async (abort) => {
363
+ const result = await installWslNeurocode(appVersion, name, { signal: abort.signal })
364
+ if (result.code !== 0) {
365
+ throw new Error(summarize(result.stderr || result.stdout) || "NeuroCode installation failed")
366
+ }
367
+ await refreshNeurocodeCheck(name, { signal: abort.signal })
368
+ expectNeurocodeVersion(state.neurocodeChecks[name]?.version ?? null, appVersion, name)
369
+ const id = wslServerIdToRestart(state.servers, name)
370
+ if (id) await startServer(id)
371
+ })
372
+ },
373
+
374
+ async openTerminal(name: string) {
375
+ await openWslTerminal(name)
376
+ },
377
+
378
+ async addServer(distro: string): Promise<WslServerConfig> {
379
+ const id = wslServerIdForDistro(distro)
380
+ if (state.servers.some((item) => item.config.id === id)) {
381
+ throw new Error(`${distro} is already added`)
382
+ }
383
+ const config: WslServerConfig = {
384
+ id,
385
+ distro,
386
+ }
387
+ persistServers([...readServers(), config])
388
+ setState({
389
+ servers: [...state.servers, { config, runtime: { kind: "starting" } }],
390
+ })
391
+ void startServer(id)
392
+ return config
393
+ },
394
+
395
+ async removeServer(id: string) {
396
+ const distro = state.servers.find((item) => item.config.id === id)?.config.distro
397
+ invalidateStartAttempt(id)
398
+ await stopServerInternal(id)
399
+ const remaining = readServers().filter((item) => item.id !== id)
400
+ persistServers(remaining)
401
+ setState({
402
+ servers: state.servers.filter((item) => item.config.id !== id),
403
+ ...(distro ? clearWslDistroState(state.distroProbes, state.neurocodeChecks, distro) : {}),
404
+ })
405
+ },
406
+
407
+ startServer,
408
+
409
+ stopAll() {
410
+ for (const item of state.servers) invalidateStartAttempt(item.config.id)
411
+ for (const existing of sidecars.values()) {
412
+ try {
413
+ existing.listener.stop()
414
+ } catch {
415
+ // ignore
416
+ }
417
+ }
418
+ sidecars.clear()
419
+ },
420
+ }
421
+ }
422
+
423
+ function initialState(): WslServersState {
424
+ return {
425
+ runtime: null,
426
+ installed: [],
427
+ online: [],
428
+ distroProbes: {},
429
+ neurocodeChecks: {},
430
+ pendingRestart: false,
431
+ servers: [],
432
+ job: null,
433
+ }
434
+ }
435
+
436
+ function readPersistedServers(): WslServerConfig[] {
437
+ const store = getStore()
438
+ const existing = store.get(WSL_SERVERS_KEY)
439
+ if (existing && typeof existing === "object") {
440
+ const record = existing as { servers?: unknown }
441
+ const list = Array.isArray(record.servers) ? record.servers : []
442
+ return list.flatMap(normalizePersistedServer)
443
+ }
444
+ return []
445
+ }
446
+
447
+ function writePersistedServers(servers: WslServerConfig[]) {
448
+ getStore().set(WSL_SERVERS_KEY, { servers })
449
+ }
450
+
451
+ function normalizePersistedServer(value: unknown): WslServerConfig[] {
452
+ if (!value || typeof value !== "object") return []
453
+ const record = value as Record<string, unknown>
454
+ const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
455
+ if (!distro) return []
456
+ const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
457
+ return [
458
+ {
459
+ id,
460
+ distro,
461
+ },
462
+ ]
463
+ }
464
+
465
+ function neurocodeCheck(
466
+ distro: string,
467
+ resolvedPath: string | null,
468
+ version: string | null,
469
+ expectedVersion: string,
470
+ ): WslNeurocodeCheck {
471
+ if (!resolvedPath) {
472
+ return {
473
+ distro,
474
+ resolvedPath: null,
475
+ version: null,
476
+ expectedVersion,
477
+ matchesDesktop: null,
478
+ error: "neurocode is not installed in this distro",
479
+ }
480
+ }
481
+ if (!version) {
482
+ return {
483
+ distro,
484
+ resolvedPath,
485
+ version: null,
486
+ expectedVersion,
487
+ matchesDesktop: null,
488
+ error: "neurocode is installed but could not run",
489
+ }
490
+ }
491
+ return {
492
+ distro,
493
+ resolvedPath,
494
+ version,
495
+ expectedVersion,
496
+ matchesDesktop: version === expectedVersion,
497
+ error: null,
498
+ }
499
+ }
500
+
501
+ function distroProbeReady(probe: WslDistroProbe | undefined) {
502
+ return !!probe?.canExecute && probe.hasBash && probe.hasCurl
503
+ }
504
+
505
+ function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
506
+ return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
507
+ }
508
+
509
+ // Re-export types used by callers
510
+ export type {
511
+ WslInstalledDistro,
512
+ WslOnlineDistro,
513
+ WslRuntimeCheck,
514
+ WslDistroProbe,
515
+ WslNeurocodeCheck,
516
+ WslServerConfig,
517
+ WslServerItem,
518
+ WslServerRuntime,
519
+ WslServersEvent,
520
+ WslServersState,
521
+ }
@@ -0,0 +1,129 @@
1
+ import { spawn } from "node:child_process"
2
+ import { randomUUID } from "node:crypto"
3
+ import { createServer } from "node:net"
4
+ import { app } from "electron"
5
+ import { checkHealth } from "../server"
6
+ import { type WslCommandLine, resolveWslNeurocode, shellEscape, wslArgs } from "./runtime"
7
+ import { pollWslHealth } from "./startup"
8
+
9
+ export type WslSidecar = {
10
+ listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
11
+ url: string
12
+ username: string | null
13
+ password: string
14
+ }
15
+
16
+ export async function spawnWslSidecar(
17
+ distro: string,
18
+ opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
19
+ ): Promise<WslSidecar> {
20
+ const neurocodePath = await resolveWslNeurocode(distro)
21
+ if (!neurocodePath) throw new Error(`NeuroCode is not installed in ${distro}`)
22
+
23
+ const port = await allocatePort()
24
+ const password = randomUUID()
25
+ const username = "neurocode"
26
+ const script = [
27
+ "set -euo pipefail",
28
+ 'cd "$HOME" || cd /',
29
+ 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
30
+ "export PATH",
31
+ "export WSLENV=",
32
+ "export NEUROCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
33
+ "export NEUROCODE_CLIENT=desktop",
34
+ `export NEUROCODE_SERVER_USERNAME=${shellEscape(username)}`,
35
+ `export NEUROCODE_SERVER_PASSWORD=${shellEscape(password)}`,
36
+ 'export XDG_STATE_HOME="$HOME/.local/state"',
37
+ `exec ${shellEscape(neurocodePath)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`,
38
+ ].join("\n")
39
+ const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
40
+ stdio: ["pipe", "pipe", "pipe"],
41
+ windowsHide: true,
42
+ })
43
+ child.stdin.end(script)
44
+
45
+ const recentOutput: string[] = []
46
+ const emit = (line: WslCommandLine) => {
47
+ if (!line.text.trim()) return
48
+ recentOutput.push(`[${line.stream}] ${line.text}`)
49
+ if (recentOutput.length > 12) recentOutput.shift()
50
+ opts.onLine?.(line)
51
+ }
52
+ forwardLines(child.stdout, "stdout", emit)
53
+ forwardLines(child.stderr, "stderr", emit)
54
+
55
+ const exit = new Promise<never>((_, reject) => {
56
+ child.once("error", reject)
57
+ child.once("exit", (code, signal) => reject(new Error(startupFailure(code, signal, recentOutput))))
58
+ })
59
+ const url = `http://127.0.0.1:${port}`
60
+ const startup = new AbortController()
61
+ const health = pollWslHealth(() => checkHealth(url, password), startup.signal)
62
+ const timeoutMs = opts.healthTimeoutMs ?? 30_000
63
+ let timeout: ReturnType<typeof setTimeout>
64
+ const timedOut = new Promise<never>(
65
+ (_, reject) =>
66
+ (timeout = setTimeout(
67
+ () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
68
+ timeoutMs,
69
+ )),
70
+ )
71
+
72
+ await Promise.race([health, exit, timedOut])
73
+ .catch((error) => {
74
+ child.kill()
75
+ throw error
76
+ })
77
+ .finally(() => {
78
+ clearTimeout(timeout)
79
+ startup.abort()
80
+ })
81
+ return {
82
+ listener: {
83
+ stop: () => child.kill(),
84
+ onExit: (cb) => child.once("exit", cb),
85
+ },
86
+ url,
87
+ username,
88
+ password,
89
+ }
90
+ }
91
+
92
+ function allocatePort() {
93
+ return new Promise<number>((resolve, reject) => {
94
+ const server = createServer()
95
+ server.on("error", reject)
96
+ server.listen(0, "127.0.0.1", () => {
97
+ const address = server.address()
98
+ if (typeof address !== "object" || !address) {
99
+ server.close()
100
+ reject(new Error("Failed to get port"))
101
+ return
102
+ }
103
+ server.close(() => resolve(address.port))
104
+ })
105
+ })
106
+ }
107
+
108
+ function forwardLines(
109
+ stream: NodeJS.ReadableStream,
110
+ source: WslCommandLine["stream"],
111
+ onLine: (line: WslCommandLine) => void,
112
+ ) {
113
+ let pending = ""
114
+ stream.setEncoding("utf8")
115
+ stream.on("data", (chunk: string) => {
116
+ pending += chunk
117
+ const lines = pending.split(/\r?\n/g)
118
+ pending = lines.pop() ?? ""
119
+ lines.forEach((text) => onLine({ stream: source, text }))
120
+ })
121
+ stream.on("end", () => {
122
+ if (pending) onLine({ stream: source, text: pending })
123
+ })
124
+ }
125
+
126
+ function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
127
+ const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
128
+ return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
129
+ }
@@ -0,0 +1,31 @@
1
+ export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
2
+ return servers.map((server) => server.id)
3
+ }
4
+
5
+ export function expectNeurocodeVersion(installed: string | null, expected: string, distro = "Debian") {
6
+ if (installed === expected) return
7
+ throw new Error(
8
+ `Neurocode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`,
9
+ )
10
+ }
11
+
12
+ export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
13
+
14
+ export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
15
+ while (!signal.aborted) {
16
+ if (await check()) return
17
+ await abortableDelay(interval, signal)
18
+ }
19
+ }
20
+
21
+ function abortableDelay(duration: number, signal: AbortSignal) {
22
+ return new Promise<void>((resolve) => {
23
+ const done = () => {
24
+ clearTimeout(timeout)
25
+ signal.removeEventListener("abort", done)
26
+ resolve()
27
+ }
28
+ const timeout = setTimeout(done, duration)
29
+ signal.addEventListener("abort", done, { once: true })
30
+ })
31
+ }