feat(desktop): make updates persistent and responsive (#31191)
This commit is contained in:
parent
f20655bef4
commit
9b4d5b0395
26 changed files with 509 additions and 396 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
111
packages/desktop/src/main/updater-controller.test.ts
Normal file
111
packages/desktop/src/main/updater-controller.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
99
packages/desktop/src/main/updater-controller.ts
Normal file
99
packages/desktop/src/main/updater-controller.ts
Normal 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>
|
||||
16
packages/desktop/src/main/updater-subscriptions.test.ts
Normal file
16
packages/desktop/src/main/updater-subscriptions.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
20
packages/desktop/src/main/updater-subscriptions.ts
Normal file
20
packages/desktop/src/main/updater-subscriptions.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue