feat(desktop): make updates persistent and responsive (#31191)

This commit is contained in:
Luke Parker 2026-06-07 15:20:56 +10:00 committed by GitHub
commit 9b4d5b0395
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 509 additions and 396 deletions

View file

@ -13,7 +13,7 @@ import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { CHANNEL } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
@ -26,7 +26,7 @@ import {
spawnLocalServer,
type SidecarListener,
} from "./server"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import {
createMainWindow,
registerRendererProtocol,
@ -232,6 +232,13 @@ const main = Effect.gen(function* () {
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
@ -244,7 +251,6 @@ const main = Effect.gen(function* () {
},
(e) => Effect.runPromise(e),
),
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
@ -253,22 +259,17 @@ const main = Effect.gen(function* () {
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(stopSidecars),
updater,
showUpdater: () => showUpdaterDialog(updater, true),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
})
registerWslIpcHandlers(wslServers)
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
app.once("will-quit", () => clearInterval(updateTimer))
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
@ -350,7 +351,7 @@ const main = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void checkForUpdates(true, stopSidecars)
void showUpdaterDialog(updater, true)
},
relaunch: () => {
relaunch()

View file

@ -1,12 +1,14 @@
import { execFile } from "node:child_process"
import { BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme, WindowConfig } from "../preload/types"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
@ -17,7 +19,6 @@ type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@ -26,18 +27,19 @@ type Deps = {
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
runUpdater: (alertOnFail: boolean) => Promise<void> | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
updater: UpdaterController
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
}
export function registerIpcHandlers(deps: Deps) {
const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
@ -50,9 +52,20 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
updaterSubscriptions.set(
id,
deps.updater.subscribe((state) => {
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => deps.updater.check())
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
@ -191,7 +204,10 @@ export function registerIpcHandlers(deps: Deps) {
setTitlebar(win, theme)
})
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
})
})
}

View file

@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const backend: UpdaterBackend = {
async checkForUpdates() {
calls.push("check")
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
},
async downloadUpdate() {
calls.push("download")
},
quitAndInstall() {
calls.push("install")
},
}
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
backend,
persistence: {
get: () => ready,
set: (value) => {
ready = value
},
clear: () => {
ready = undefined
},
},
stop: async () => {
calls.push("stop")
},
})
return { controller, calls, getReady: () => ready }
}
describe("updater controller", () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.getReady()).toEqual({ version: "2.0.0" })
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("revalidates a persisted target through the updater cache on launch", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("clears a target already installed before checking", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.getReady()).toBeUndefined()
expect(app.calls).toEqual(["check"])
})
test("coalesces concurrent checks", async () => {
const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
expect(app.calls).toEqual(["check", "download"])
})
test("returns to ready when quitAndInstall returns without exiting", async () => {
const app = setup()
await app.controller.start()
await app.controller.install()
expect(app.calls).toEqual(["check", "download", "stop", "install"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("returns to ready when installation cannot start", async () => {
const app = setup()
await app.controller.start()
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
backend: {
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
downloadUpdate: async () => {},
quitAndInstall() {},
},
persistence: { get: () => undefined, set() {}, clear() {} },
stop: async () => {
throw new Error("stop failed")
},
})
await failed.start()
await expect(failed.install()).rejects.toThrow("stop failed")
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
})

View file

@ -0,0 +1,99 @@
import type { UpdaterState } from "@opencode-ai/app/updater"
export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterBackend = {
checkForUpdates(): Promise<
| { isUpdateAvailable?: boolean; updateInfo?: { version?: string } }
| null
| undefined
>
downloadUpdate(): Promise<unknown>
quitAndInstall(): void
}
type UpdaterPersistence = {
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
set(value: UpdaterReadyRecord): void | Promise<void>
clear(): void | Promise<void>
}
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
backend: UpdaterBackend
persistence: UpdaterPersistence
stop: () => Promise<void>
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const transition = (next: UpdaterState) => {
input.log?.("updater state changed", { from: state.status, to: next.status })
state = next
listeners.forEach((listener) => listener(state))
return state
}
const check = () => {
if (!input.enabled) return Promise.resolve(state)
if (state.status === "ready") return Promise.resolve(state)
if (pending) return pending
pending = (async () => {
transition({ status: "checking" })
const result = await input.backend.checkForUpdates()
const version = result?.updateInfo?.version
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await input.backend.downloadUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})()
.catch((error) => transition({ status: "error", message: error instanceof Error ? error.message : String(error) }))
.finally(() => {
pending = undefined
})
return pending
}
return {
getState: () => state,
subscribe(listener: (state: UpdaterState) => void) {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
},
async start() {
const ready = await input.persistence.get()
if (ready?.version === input.currentVersion) await input.persistence.clear()
return check()
},
check,
async install() {
if (state.status !== "ready") throw new Error("Update is not ready to install")
const version = state.version
transition({ status: "installing", version })
await input
.stop()
.then(() => {
input.backend.quitAndInstall()
transition({ status: "ready", version })
})
.catch((error) => {
transition({ status: "ready", version })
throw error
})
},
}
}
export type UpdaterController = ReturnType<typeof createUpdaterController>

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
describe("updater subscriptions", () => {
test("replaces the previous renderer subscription on reload", () => {
const subscriptions = createUpdaterSubscriptions()
const disposed: string[] = []
subscriptions.set(1, () => disposed.push("first"))
subscriptions.set(1, () => disposed.push("second"))
expect(disposed).toEqual(["first"])
subscriptions.delete(1)
expect(disposed).toEqual(["first", "second"])
})
})

View file

@ -0,0 +1,20 @@
export function createUpdaterSubscriptions() {
const subscriptions = new Map<number, () => void>()
const remove = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
return {
set(id: number, unsubscribe: () => void) {
remove(id)
subscriptions.set(id, unsubscribe)
},
delete: remove,
clear() {
subscriptions.forEach((unsubscribe) => unsubscribe())
subscriptions.clear()
},
}
}

View file

@ -1,15 +1,14 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
const { autoUpdater } = pkg
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
let downloadedVersion: string | undefined
let pendingCheck: Promise<UpdateCheckResult> | undefined
const key = "ready"
export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
export function setupAutoUpdater(stop: () => Promise<void>) {
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
@ -23,110 +22,50 @@ export function setupAutoUpdater() {
allowDowngrade: autoUpdater.allowDowngrade,
currentVersion: app.getVersion(),
})
}
export async function checkUpdate(): Promise<UpdateCheckResult> {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
if (pendingCheck) return pendingCheck
pendingCheck = checkAndDownloadUpdate().finally(() => {
pendingCheck = undefined
})
return pendingCheck
}
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
const logger = getLogger()
logger.log("checking for updates", {
const store = getStore("opencode.updater")
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
allowPrerelease: autoUpdater.allowPrerelease,
allowDowngrade: autoUpdater.allowDowngrade,
backend: autoUpdater,
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
return { version: value.version } satisfies UpdaterReadyRecord
},
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
stop,
log: (message, data) => logger.log(message, data),
})
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()
downloadedVersion = version
logger.log("update download completed", { version })
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
return { updateAvailable: false, failed: true }
}
}
export async function installUpdate(killSidecar: () => Promise<void>) {
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
const logger = getLogger()
if (!result.updateAvailable || !downloadedVersion) {
logger.log("install update skipped", {
reason: result.failed ? "update check failed" : "no update available",
})
return
}
logger.log("installing downloaded update", {
version: result.version ?? null,
})
await killSidecar()
autoUpdater.quitAndInstall()
}
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
if (!UPDATER_ENABLED) return
const logger = getLogger()
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" })
export async function showUpdaterDialog(
controller: ReturnType<typeof setupAutoUpdater>,
alertOnFail: boolean,
) {
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({
type: "info",
message: "You're up to date.",
title: "No Updates",
})
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
message: `Update ${state.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)
}
if (response.response === 0) await controller.install()
}

View file

@ -1,5 +1,14 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
const updaterHandler = (_: unknown, state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@ -28,7 +37,26 @@ const api: ElectronAPI = {
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
},
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
ipcRenderer.on("updater-state", updaterHandler)
updaterSubscription = ipcRenderer.invoke("updater-subscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
ipcRenderer.removeListener("updater-state", updaterHandler)
updaterSubscription = undefined
void ipcRenderer.invoke("updater-unsubscribe")
}
},
check: () => ipcRenderer.invoke("updater-check"),
install: () => ipcRenderer.invoke("updater-install"),
},
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
@ -83,9 +111,6 @@ const api: ElectronAPI = {
},
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),

View file

@ -1,5 +1,6 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { UpdaterState } from "@opencode-ai/app/updater"
export type {
WslDistroProbe,
WslInstalledDistro,
@ -21,15 +22,16 @@ export type ServerReadyData = {
}
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
check: () => Promise<UpdaterState>
install: () => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
export type WindowConfig = {
updaterEnabled: boolean
}
export type FatalRendererError = {
error: string
url: string
@ -43,7 +45,7 @@ export type ElectronAPI = {
installCli: () => Promise<string>
awaitInitialization: () => Promise<ServerReadyData>
wslServers: WslServersAPI
getWindowConfig: () => Promise<WindowConfig>
updater: UpdaterAPI
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
@ -92,9 +94,6 @@ export type ElectronAPI = {
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>

View file

@ -15,10 +15,11 @@ import {
useCommand,
useWslServers,
} from "@opencode-ai/app"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
@ -59,6 +60,9 @@ if (import.meta.env.VITE_SENTRY_DSN) {
void initI18n()
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
void window.api.updater.subscribe(setUpdaterState)
const deepLinkEvent = "opencode:deep-link"
const emitDeepLinks = (urls: string[]) => {
@ -177,16 +181,10 @@ const createPlatform = (): Platform => {
storage,
checkUpdate: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return { updateAvailable: false }
return window.api.checkUpdate()
},
updateAndRestart: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return
await window.api.installUpdate()
updater: {
state: updaterState,
check: () => window.api.updater.check(),
install: () => window.api.updater.install(),
},
exportDebugLogs: () => window.api.exportDebugLogs(),

View file

@ -1,12 +0,0 @@
import { initI18n, t } from "./i18n"
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
try {
await window.api.runUpdater(alertOnFail)
} catch {
if (alertOnFail) {
window.alert(t("desktop.updater.checkFailed.message"))
}
}
}