index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import { randomUUID } from "node:crypto"
  2. import { mkdirSync, rmSync } from "node:fs"
  3. import * as http from "node:http"
  4. import { createServer } from "node:net"
  5. import { homedir, tmpdir } from "node:os"
  6. import { join } from "node:path"
  7. import { getCACertificates, setDefaultCACertificates } from "node:tls"
  8. import type { Event } from "electron"
  9. import { app } from "electron"
  10. import { Deferred, Effect, Fiber } from "effect"
  11. import contextMenu from "electron-context-menu"
  12. import type { ServerReadyData } from "../preload/types"
  13. import { checkAppExists, resolveAppPath } from "./apps"
  14. import { CHANNEL } from "./constants"
  15. import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
  16. import { forwardInitializationFailure } from "./initialization"
  17. import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
  18. import { parseMarkdown } from "./markdown"
  19. import { createMenu } from "./menu"
  20. import { ensureDefaultModes, finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
  21. import {
  22. getDefaultServerUrl,
  23. preferAppEnv,
  24. setDefaultServerUrl,
  25. spawnLocalServer,
  26. type SidecarListener,
  27. } from "./server"
  28. import { setupAutoUpdater, showUpdaterDialog } from "./updater"
  29. import {
  30. getLastFocusedWindow,
  31. registerRendererProtocol,
  32. setRelaunchHandler,
  33. setAppQuitting,
  34. setBackgroundColor,
  35. setDockIcon,
  36. restoreMainWindows,
  37. } from "./windows"
  38. import { createWslServersController } from "./wsl/servers"
  39. import { registerWslIpcHandlers } from "./wsl/ipc"
  40. import { spawnWslSidecar } from "./wsl/sidecar"
  41. import { migrate } from "./migrate"
  42. import { cleanupStoreFiles } from "./store-cleanup"
  43. const APP_NAMES: Record<string, string> = {
  44. dev: "KirinCode Dev",
  45. beta: "KirinCode Beta",
  46. prod: "KirinCode",
  47. }
  48. const APP_IDS: Record<string, string> = {
  49. dev: "ai.kirincode.desktop.dev",
  50. beta: "ai.kirincode.desktop.beta",
  51. prod: "ai.kirincode.desktop",
  52. }
  53. const TEST_ONBOARDING = process.env.KIRINCODE_TEST_ONBOARDING === "1"
  54. const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
  55. let logger: ReturnType<typeof initLogging>
  56. let server: SidecarListener | null = null
  57. const pendingDeepLinks: string[] = []
  58. function useEnvProxy() {
  59. try {
  60. // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
  61. ;(http as any).setGlobalProxyFromEnv()
  62. } catch (error) {
  63. logger.warn("failed to load proxy environment", error)
  64. }
  65. }
  66. function emitDeepLinks(urls: string[]) {
  67. if (urls.length === 0) return
  68. pendingDeepLinks.push(...urls)
  69. const win = getLastFocusedWindow()
  70. if (win) sendDeepLinks(win, urls)
  71. }
  72. async function killSidecar() {
  73. if (!server) return
  74. const current = server
  75. server = null
  76. await current.stop()
  77. }
  78. function ensureLoopbackNoProxy() {
  79. const loopback = ["127.0.0.1", "localhost", "::1"]
  80. const upsert = (key: string) => {
  81. const items = (process.env[key] ?? "")
  82. .split(",")
  83. .map((value: string) => value.trim())
  84. .filter((value: string) => Boolean(value))
  85. for (const host of loopback) {
  86. if (items.some((value: string) => value.toLowerCase() === host)) continue
  87. items.push(host)
  88. }
  89. process.env[key] = items.join(",")
  90. }
  91. upsert("NO_PROXY")
  92. upsert("no_proxy")
  93. }
  94. const main = Effect.gen(function* () {
  95. contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
  96. // on macOS apps run in `/` which can cause issues with ripgrep
  97. try {
  98. process.chdir(homedir())
  99. } catch {}
  100. process.env.KIRINCODE_DISABLE_EMBEDDED_WEB_UI = "true"
  101. const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.kirincode.desktop.dev"
  102. const onboardingTestRoot = ((): string | undefined => {
  103. if (!TEST_ONBOARDING) return
  104. const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
  105. rmSync(root, { recursive: true, force: true })
  106. ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
  107. mkdirSync(join(root, dir), { recursive: true }),
  108. )
  109. process.env.KIRINCODE_DB = ":memory:"
  110. process.env.XDG_DATA_HOME = join(root, "data")
  111. process.env.XDG_CONFIG_HOME = join(root, "config")
  112. process.env.XDG_CACHE_HOME = join(root, "cache")
  113. process.env.XDG_STATE_HOME = join(root, "state")
  114. return root
  115. })()
  116. app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "KirinCode Dev")
  117. app.setAppUserModelId(appId)
  118. app.setPath(
  119. "userData",
  120. onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
  121. )
  122. if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
  123. logger = initLogging()
  124. initCrashReporter()
  125. const wslServers = createWslServersController(
  126. app.getVersion(),
  127. async (distro) => {
  128. logger.log("spawning wsl sidecar", { distro })
  129. return spawnWslSidecar(distro, {
  130. onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
  131. })
  132. },
  133. {
  134. logger: {
  135. log: (message, meta) => logger.log(message, meta),
  136. error: (message, meta) => logger.error(message, meta),
  137. },
  138. },
  139. )
  140. const stopSidecars = async () => {
  141. await killSidecar()
  142. wslServers.stopAll()
  143. }
  144. const relaunch = () => {
  145. setAppQuitting()
  146. void stopSidecars().finally(() => {
  147. app.relaunch()
  148. app.exit(0)
  149. })
  150. }
  151. try {
  152. setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
  153. } catch (error) {
  154. logger.warn("failed to load system certificates", error)
  155. }
  156. logger.log("app starting", {
  157. version: app.getVersion(),
  158. packaged: app.isPackaged,
  159. onboardingTest: Boolean(onboardingTestRoot),
  160. })
  161. ensureLoopbackNoProxy()
  162. useEnvProxy()
  163. app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
  164. const features = app.commandLine.getSwitchValue("enable-features")
  165. app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
  166. if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
  167. if (!app.requestSingleInstanceLock()) {
  168. app.quit()
  169. return
  170. }
  171. preferAppEnv(app.getPath("userData"))
  172. app.on("second-instance", (_event: Event, argv: string[]) => {
  173. const urls = argv.filter((arg: string) => arg.startsWith("kirincode://"))
  174. if (urls.length) {
  175. logger.log("deep link received via second-instance", { urls })
  176. emitDeepLinks(urls)
  177. }
  178. const win = getLastFocusedWindow()
  179. if (win) {
  180. win.show()
  181. win.focus()
  182. }
  183. })
  184. app.on("open-url", (event: Event, url: string) => {
  185. event.preventDefault()
  186. logger.log("deep link received via open-url", { url })
  187. emitDeepLinks([url])
  188. })
  189. app.on("before-quit", () => {
  190. setAppQuitting()
  191. void stopSidecars()
  192. })
  193. app.on("will-quit", () => {
  194. setAppQuitting()
  195. void stopSidecars()
  196. })
  197. app.on("child-process-gone", (_event, details) => {
  198. writeLog("utility", "child process gone", { details }, "error")
  199. })
  200. app.on("render-process-gone", (_event, webContents, details) => {
  201. writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
  202. })
  203. setRelaunchHandler(() => {
  204. relaunch()
  205. })
  206. for (const signal of ["SIGINT", "SIGTERM"] as const) {
  207. process.on(signal, () => {
  208. setAppQuitting()
  209. void stopSidecars().finally(() => app.exit(0))
  210. })
  211. }
  212. const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
  213. yield* Effect.promise(() => app.whenReady())
  214. yield* Effect.promise(() => ensureDefaultModes())
  215. if (!TEST_ONBOARDING) migrate()
  216. yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
  217. Effect.tap((result) =>
  218. Effect.sync(() => {
  219. if (result.deleted.length === 0) return
  220. logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
  221. }),
  222. ),
  223. Effect.catch((error) =>
  224. Effect.sync(() => {
  225. logger.warn("failed to clean scoped store files", error)
  226. }),
  227. ),
  228. )
  229. app.setAsDefaultProtocolClient("kirincode")
  230. registerRendererProtocol()
  231. setDockIcon()
  232. const updater = setupAutoUpdater(stopSidecars)
  233. registerIpcHandlers({
  234. killSidecar: () => killSidecar(),
  235. relaunch,
  236. awaitInitialization: Effect.fnUntraced(
  237. function* () {
  238. logger.log("awaiting server ready")
  239. const res = yield* Deferred.await(serverReady)
  240. logger.log("server ready", { url: res.url })
  241. return res
  242. },
  243. (e) => Effect.runPromise(e),
  244. ),
  245. consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
  246. getDefaultServerUrl: () => getDefaultServerUrl(),
  247. setDefaultServerUrl: (url) => setDefaultServerUrl(url),
  248. isFirstLaunchOnboardingPending,
  249. finishFirstLaunchOnboarding,
  250. getDisplayBackend: async () => null,
  251. setDisplayBackend: async () => undefined,
  252. parseMarkdown: async (markdown) => parseMarkdown(markdown),
  253. checkAppExists: (appName) => checkAppExists(appName),
  254. resolveAppPath: async (appName) => resolveAppPath(appName),
  255. updater,
  256. showUpdater: () => showUpdaterDialog(updater, true),
  257. setBackgroundColor: (color) => setBackgroundColor(color),
  258. exportDebugLogs: () => exportDebugLogs(),
  259. recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
  260. })
  261. registerWslIpcHandlers(wslServers)
  262. void updater.start()
  263. const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
  264. updateTimer.unref()
  265. app.once("will-quit", () => clearInterval(updateTimer))
  266. yield* Effect.promise(() => startNetLog()).pipe(
  267. Effect.catch((error) =>
  268. Effect.sync(() => {
  269. logger.warn("failed to start net log", error)
  270. }),
  271. ),
  272. )
  273. const port = yield* Effect.gen(function* () {
  274. const fromEnv = process.env.KIRINCODE_PORT
  275. if (fromEnv) {
  276. const parsed = Number.parseInt(fromEnv, 10)
  277. if (!Number.isNaN(parsed)) return parsed
  278. }
  279. const res = yield* Deferred.make<number, unknown>()
  280. const server = createServer()
  281. server.on("error", (e) => Deferred.failSync(res, () => e))
  282. server.listen(0, "127.0.0.1", () => {
  283. const address = server.address()
  284. if (typeof address !== "object" || !address) {
  285. server.close()
  286. Deferred.failSync(res, () => new Error("Failed to get port"))
  287. return
  288. }
  289. const port = address.port
  290. server.close(() => Effect.runSync(Deferred.succeed(res, port)))
  291. })
  292. return yield* Deferred.await(res)
  293. })
  294. const hostname = "127.0.0.1"
  295. const url = `http://${hostname}:${port}`
  296. const password = randomUUID()
  297. const loadingTask = yield* Effect.gen(function* () {
  298. logger.log("sidecar connection started", { url })
  299. ensureLoopbackNoProxy()
  300. useEnvProxy()
  301. logger.log("spawning sidecar", { url })
  302. const { listener, health } = yield* Effect.promise(() =>
  303. spawnLocalServer(hostname, port, password, {
  304. userDataPath: app.getPath("userData"),
  305. onStdout: (message) => writeLog("server", "stdout", { message }),
  306. onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
  307. onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
  308. }),
  309. )
  310. server = listener
  311. yield* Deferred.succeed(serverReady, {
  312. url,
  313. username: "kirincode",
  314. password,
  315. })
  316. if (process.platform === "win32") {
  317. void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
  318. }
  319. yield* Effect.promise(() => health.wait).pipe(
  320. Effect.timeout("30 seconds"),
  321. Effect.catch((e) =>
  322. Effect.sync(() => {
  323. logger.error("sidecar health check failed", e.toString())
  324. }),
  325. ),
  326. )
  327. logger.log("loading task finished")
  328. }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
  329. yield* Fiber.await(loadingTask)
  330. const windows = restoreMainWindows()
  331. if (windows.length) {
  332. createMenu({
  333. trigger: (id) => {
  334. const win = getLastFocusedWindow()
  335. if (win) sendMenuCommand(win, id)
  336. },
  337. checkForUpdates: () => {
  338. void showUpdaterDialog(updater, true)
  339. },
  340. relaunch: () => {
  341. relaunch()
  342. },
  343. })
  344. }
  345. })
  346. Effect.runFork(main)