diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702c..1b624800e8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" -import pkg from "electron-updater" +import { app, BrowserWindow } from "electron" import contextMenu from "electron-context-menu" -contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - -// on macOS apps run in `/` which can cause issues with ripgrep -try { - process.chdir(homedir()) -} catch {} - -process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - -const APP_NAMES: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" -const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -const onboardingTestRoot = setupOnboardingTestEnv() -app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setAppUserModelId(appId) -app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) -if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) -const logger = initLogging() -const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" @@ -64,104 +35,30 @@ import { setDockIcon, } from "./windows" import { migrate } from "./migrate" +import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater" +import { Deferred, Effect, Fiber } from "effect" + +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" + +let logger: ReturnType +let mainWindow: BrowserWindow | null = null +let server: SidecarListener | null = null const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } -let mainWindow: BrowserWindow | null = null -let server: SidecarListener | null = null -const loadingComplete = defer() - const pendingDeepLinks: string[] = [] -const serverReady = defer() - -useSystemCertificates() - -function setupOnboardingTestEnv() { - if (!TEST_ONBOARDING) return - - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), - ) - process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") - return root -} - -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingTestRoot), -}) - -setupApp() - -function setupApp() { - ensureLoopbackNoProxy() - useEnvProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") - - if (!app.requestSingleInstanceLock()) { - app.quit() - return - } - - preferAppEnv(app.getPath("userData")) - - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) - } - focusMainWindow() - }) - - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) - - app.on("before-quit", () => { - void killSidecar() - }) - - app.on("will-quit", () => { - void killSidecar() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - void killSidecar().finally(() => app.exit(0)) - }) - } - - void app.whenReady().then(async () => { - if (!TEST_ONBOARDING) migrate() - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() - }) -} - -function useSystemCertificates() { - try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} - function useEnvProxy() { try { // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. @@ -177,145 +74,12 @@ function emitDeepLinks(urls: string[]) { if (mainWindow) sendDeepLinks(mainWindow, urls) } -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} - function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) initEmitter.emit("step", step) } -async function initialize() { - const needsMigration = !sqliteFileExists() - let overlay: BrowserWindow | null = null - - const port = await getSidecarPort() - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - - const loadingTask = (async () => { - logger.log("sidecar connection started", { url }) - - initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (overlay) sendSqliteMigrationProgress(overlay, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - }) - - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ) - server = listener - serverReady.resolve({ - url, - username: "opencode", - password, - }) - - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) - - logger.log("loading task finished") - })() - - if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow() - await delay(1_000) - } - } - - await loadingTask - setInitStep({ phase: "done" }) - - if (overlay) { - await loadingComplete.promise - } - - mainWindow = createMainWindow() - wireMenu() - - overlay?.close() -} - -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - void killSidecar().finally(() => { - app.relaunch() - app.exit(0) - }) - }, - }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), -}) - async function killSidecar() { if (!server) return const current = server @@ -343,163 +107,265 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + logger = initLogging() + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) } - return await new Promise((resolve, reject) => { + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) + + ensureLoopbackNoProxy() + useEnvProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + preferAppEnv(app.getPath("userData")) + + app.on("second-instance", (_event: Event, argv: string[]) => { + const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + emitDeepLinks(urls) + } + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }) + + app.on("open-url", (event: Event, url: string) => { + event.preventDefault() + logger.log("deep link received via open-url", { url }) + emitDeepLinks([url]) + }) + + app.on("before-quit", () => { + void killSidecar() + }) + + app.on("will-quit", () => { + void killSidecar() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void killSidecar().finally(() => app.exit(0)) + }) + } + + const serverReady = Deferred.makeUnsafe() + const loadingComplete = Deferred.makeUnsafe() + + registerIpcHandlers({ + killSidecar: () => killSidecar(), + awaitInitialization: Effect.fnUntraced( + function* (sendStep) { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) + initEmitter.on("step", listener) + try { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + } finally { + initEmitter.off("step", listener) + } + }, + (e) => Effect.runPromise(e), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + parseMarkdown: async (markdown) => parseMarkdown(markdown), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: async (path, mode) => wslPath(path, mode), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar), + checkUpdate: async () => checkUpdate(), + installUpdate: async () => installUpdate(killSidecar), + setBackgroundColor: (color) => setBackgroundColor(color), + }) + + yield* Effect.promise(() => app.whenReady()) + + if (!TEST_ONBOARDING) migrate() + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() + + const needsMigration = ((): boolean => { + if (process.env.OPENCODE_DB === ":memory:") return false + + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") + return !existsSync(join(base, "opencode", "opencode.db")) + })() + let overlay: BrowserWindow | null = null + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() const server = createServer() - server.on("error", reject) + server.on("error", (e) => Deferred.failSync(res, () => e)) server.listen(0, "127.0.0.1", () => { const address = server.address() if (typeof address !== "object" || !address) { server.close() - reject(new Error("Failed to get port")) + Deferred.failSync(res, () => new Error("Failed to get port")) return } const port = address.port - server.close(() => resolve(port)) + server.close(() => Effect.runSync(Deferred.succeed(res, port))) }) + + return yield* Deferred.await(res) }) -} + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() -function sqliteFileExists() { - if (process.env.OPENCODE_DB === ":memory:") return true + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { url }) - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - -function setupAutoUpdater() { - if (!UPDATER_ENABLED) return - autoUpdater.logger = logger - autoUpdater.channel = "latest" - autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - logger.log("auto updater configured", { - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - currentVersion: app.getVersion(), - }) -} - -let downloadedUpdateVersion: string | undefined - -async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - if (downloadedUpdateVersion) { - logger.log("returning cached downloaded update", { - version: downloadedUpdateVersion, + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { + setInitStep({ phase: "sqlite_waiting" }) + if (overlay) sendSqliteMigrationProgress(overlay, progress) + if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) - return { updateAvailable: true, version: downloadedUpdateVersion } - } - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], + + logger.log("spawning sidecar", { url }) + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ), + ) + server = listener + yield* Deferred.succeed(serverReady, { + url, + username: "opencode", + password, }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } + + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) + + logger.log("loading task finished") + }).pipe(Effect.forkChild) + + if (needsMigration) { + const show = yield* loadingTask.pipe( + Fiber.await, + Effect.timeout("1 second"), + Effect.as(false), + Effect.catch(() => Effect.succeed(true)), + ) + if (show) { + overlay = createLoadingWindow() + yield* Effect.sleep("1 second") } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - downloadedUpdateVersion = version - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } } -} -async function installUpdate() { - if (!downloadedUpdateVersion) { - logger.log("install update skipped", { - reason: "no downloaded update ready", + yield* Fiber.await(loadingTask) + setInitStep({ phase: "done" }) + + if (overlay) yield* Deferred.await(loadingComplete) + + mainWindow = createMainWindow() + if (mainWindow) { + createMenu({ + trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + checkForUpdates: () => { + void checkForUpdates(true, killSidecar) + }, + reload: () => mainWindow?.reload(), + relaunch: () => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }, }) - return - } - logger.log("installing downloaded update", { - version: downloadedUpdateVersion, - }) - await killSidecar() - autoUpdater.quitAndInstall(true, true) -} - -async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return - logger.log("checkForUpdates invoked", { alertOnFail }) - const result = await checkUpdate() - if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - - logger.log("no update decision", { reason: "already up to date" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "info", - message: "You're up to date.", - title: "No Updates", - }) - return } - const response = await dialog.showMessageBox({ - type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], - defaultId: 0, - cancelId: 1, - }) - logger.log("update prompt response", { - version: result.version ?? null, - restartNow: response.response === 0, - }) - if (response.response === 0) { - await installUpdate() - } -} + overlay?.close() +}) -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} +Effect.runFork(main) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000000..a220e00891 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,126 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { initLogging } from "./logging" + +const logger = initLogging() +const { autoUpdater } = pkg + +let downloadedUpdateVersion: string | undefined + +export function setupAutoUpdater() { + if (!UPDATER_ENABLED) return + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) +} + +export async function checkUpdate() { + if (!UPDATER_ENABLED) return { updateAvailable: false } + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } + logger.log("checking for updates", { + currentVersion: app.getVersion(), + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + }) + try { + const result = await autoUpdater.checkForUpdates() + const updateInfo = result?.updateInfo + logger.log("update metadata fetched", { + releaseVersion: updateInfo?.version ?? null, + releaseDate: updateInfo?.releaseDate ?? null, + releaseName: updateInfo?.releaseName ?? null, + files: updateInfo?.files?.map((file) => file.url) ?? [], + }) + const version = result?.updateInfo?.version + if (result?.isUpdateAvailable === false || !version) { + logger.log("no update available", { + reason: "provider returned no newer version", + }) + return { updateAvailable: false } + } + logger.log("update available", { version }) + await autoUpdater.downloadUpdate() + logger.log("update download completed", { version }) + downloadedUpdateVersion = version + return { updateAvailable: true, version } + } catch (error) { + logger.error("update check failed", error) + return { updateAvailable: false, failed: true } + } +} + +export async function installUpdate(killSidecar: () => Promise) { + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) + await killSidecar() + autoUpdater.quitAndInstall() +} + +export async function checkForUpdates( + alertOnFail: boolean, + killSidecar: () => Promise, +) { + if (!UPDATER_ENABLED) return + logger.log("checkForUpdates invoked", { alertOnFail }) + const result = await checkUpdate() + if (!result.updateAvailable) { + if (result.failed) { + logger.log("no update decision", { reason: "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: "Update check failed.", + title: "Update Error", + }) + return + } + + logger.log("no update decision", { reason: "already up to date" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: "You're up to date.", + title: "No Updates", + }) + return + } + + const response = await dialog.showMessageBox({ + type: "info", + message: `Update ${result.version ?? ""} downloaded. Restart now?`, + title: "Update Ready", + buttons: ["Restart", "Later"], + defaultId: 0, + cancelId: 1, + }) + logger.log("update prompt response", { + version: result.version ?? null, + restartNow: response.response === 0, + }) + if (response.response === 0) { + await installUpdate(killSidecar) + } +}