Răsfoiți Sursa

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

Luke Parker 2 luni în urmă
părinte
comite
9b4d5b0395

+ 1 - 0
packages/app/package.json

@@ -6,6 +6,7 @@
   "exports": {
     ".": "./src/index.ts",
     "./desktop-menu": "./src/desktop-menu.ts",
+    "./updater": "./src/updater.ts",
     "./wsl/types": "./src/wsl/types.ts",
     "./vite": "./vite.js",
     "./index.css": "./src/index.css"

+ 0 - 1
packages/app/src/app.tsx

@@ -70,7 +70,6 @@ function UiI18nBridge(props: ParentProps) {
 declare global {
   interface Window {
     __OPENCODE__?: {
-      updaterEnabled?: boolean
       deepLinks?: string[]
     }
     api?: {

+ 4 - 74
packages/app/src/components/settings-general.tsx

@@ -1,5 +1,4 @@
 import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
-import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Select } from "@opencode-ai/ui/select"
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { showToast } from "@/utils/toast"
 import { useParams } from "@solidjs/router"
 import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
 import { usePlatform, type DisplayBackend } from "@/context/platform"
 import { useServerSync } from "@/context/server-sync"
 import { useServerSDK } from "@/context/server-sdk"
+import { useUpdaterAction } from "./updater-action"
 import {
   monoDefault,
   monoFontFamily,
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
   const params = useParams()
   const settings = useSettings()
 
-  const [store, setStore] = createStore({
-    checking: false,
-  })
+  const updater = useUpdaterAction()
 
   const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
   const dir = createMemo(() => decode64(params.dir))
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
   }
   const desktop = createMemo(() => platform.platform === "desktop")
 
-  const check = () => {
-    if (!platform.checkUpdate) return
-    setStore("checking", true)
-
-    void platform
-      .checkUpdate()
-      .then((result) => {
-        if (!result.updateAvailable) {
-          showToast({
-            variant: "success",
-            icon: "circle-check",
-            title: language.t("settings.updates.toast.latest.title"),
-            description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
-          })
-          return
-        }
-
-        const actions = platform.updateAndRestart
-          ? [
-              {
-                label: language.t("toast.update.action.installRestart"),
-                onClick: async () => {
-                  await platform.updateAndRestart!()
-                },
-              },
-              {
-                label: language.t("toast.update.action.notYet"),
-                onClick: "dismiss" as const,
-              },
-            ]
-          : [
-              {
-                label: language.t("toast.update.action.notYet"),
-                onClick: "dismiss" as const,
-              },
-            ]
-
-        showToast({
-          persistent: true,
-          icon: "download",
-          title: language.t("toast.update.title"),
-          description: language.t("toast.update.description", { version: result.version ?? "" }),
-          actions,
-        })
-      })
-      .catch((err: unknown) => {
-        const message = err instanceof Error ? err.message : String(err)
-        showToast({ title: language.t("common.requestFailed"), description: message })
-      })
-      .finally(() => setStore("checking", false))
-  }
-
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const serverSync = useServerSync()
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
       <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
 
       <SettingsList>
-        <SettingsRow
-          title={language.t("settings.updates.row.startup.title")}
-          description={language.t("settings.updates.row.startup.description")}
-        >
-          <div data-action="settings-updates-startup">
-            <Switch
-              checked={settings.updates.startup()}
-              disabled={!platform.checkUpdate}
-              onChange={(checked) => settings.updates.setStartup(checked)}
-            />
-          </div>
-        </SettingsRow>
-
         <SettingsRow
           title={language.t("settings.general.row.releaseNotes.title")}
           description={language.t("settings.general.row.releaseNotes.description")}
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
           title={language.t("settings.updates.row.check.title")}
           description={language.t("settings.updates.row.check.description")}
         >
-          <Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
-            {store.checking
-              ? language.t("settings.updates.action.checking")
-              : language.t("settings.updates.action.checkNow")}
+          <Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
+            {language.t(updater.action().label)}
           </Button>
         </SettingsRow>
       </SettingsList>

+ 4 - 74
packages/app/src/components/settings-v2/general.tsx

@@ -1,5 +1,4 @@
 import { Component, Show, createMemo, createResource, onMount } from "solid-js"
-import { createStore } from "solid-js/store"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { Icon } from "@opencode-ai/ui/icon"
 import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { showToast } from "@/utils/toast"
 import { useParams } from "@solidjs/router"
 import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
 import { usePlatform, type DisplayBackend } from "@/context/platform"
 import { useServerSync } from "@/context/server-sync"
 import { useServerSDK } from "@/context/server-sdk"
+import { useUpdaterAction } from "../updater-action"
 import {
   monoDefault,
   monoFontFamily,
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
   const params = useParams()
   const settings = useSettings()
 
-  const [store, setStore] = createStore({
-    checking: false,
-  })
+  const updater = useUpdaterAction()
 
   const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
   const dir = createMemo(() => decode64(params.dir))
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
   }
   const desktop = createMemo(() => platform.platform === "desktop")
 
-  const check = () => {
-    if (!platform.checkUpdate) return
-    setStore("checking", true)
-
-    void platform
-      .checkUpdate()
-      .then((result) => {
-        if (!result.updateAvailable) {
-          showToast({
-            variant: "success",
-            icon: "circle-check",
-            title: language.t("settings.updates.toast.latest.title"),
-            description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
-          })
-          return
-        }
-
-        const actions = platform.updateAndRestart
-          ? [
-              {
-                label: language.t("toast.update.action.installRestart"),
-                onClick: async () => {
-                  await platform.updateAndRestart!()
-                },
-              },
-              {
-                label: language.t("toast.update.action.notYet"),
-                onClick: "dismiss" as const,
-              },
-            ]
-          : [
-              {
-                label: language.t("toast.update.action.notYet"),
-                onClick: "dismiss" as const,
-              },
-            ]
-
-        showToast({
-          persistent: true,
-          icon: "download",
-          title: language.t("toast.update.title"),
-          description: language.t("toast.update.description", { version: result.version ?? "" }),
-          actions,
-        })
-      })
-      .catch((err: unknown) => {
-        const message = err instanceof Error ? err.message : String(err)
-        showToast({ title: language.t("common.requestFailed"), description: message })
-      })
-      .finally(() => setStore("checking", false))
-  }
-
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const serverSync = useServerSync()
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
       <h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
 
       <SettingsListV2>
-        <SettingsRowV2
-          title={language.t("settings.updates.row.startup.title")}
-          description={language.t("settings.updates.row.startup.description")}
-        >
-          <div data-action="settings-updates-startup">
-            <Switch
-              checked={settings.updates.startup()}
-              disabled={!platform.checkUpdate}
-              onChange={(checked) => settings.updates.setStartup(checked)}
-            />
-          </div>
-        </SettingsRowV2>
-
         <SettingsRowV2
           title={language.t("settings.general.row.releaseNotes.title")}
           description={language.t("settings.general.row.releaseNotes.description")}
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
           title={language.t("settings.updates.row.check.title")}
           description={language.t("settings.updates.row.check.description")}
         >
-          <ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
-            {store.checking
-              ? language.t("settings.updates.action.checking")
-              : language.t("settings.updates.action.checkNow")}
+          <ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
+            {language.t(updater.action().label)}
           </ButtonV2>
         </SettingsRowV2>
       </SettingsListV2>

+ 26 - 0
packages/app/src/components/updater-action.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test"
+import { updaterAction } from "./updater-action"
+
+describe("updaterAction", () => {
+  test("disables update actions when the platform has no updater", () => {
+    expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
+  })
+
+  test("projects updater transitions into one settings action", () => {
+    expect(updaterAction({ status: "idle" })).toEqual({
+      label: "settings.updates.action.checkNow",
+      run: "check",
+    })
+    expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
+    expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
+      label: "settings.updates.action.downloading",
+    })
+    expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
+      label: "toast.update.action.installRestart",
+      run: "install",
+    })
+    expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
+      label: "settings.updates.action.installing",
+    })
+  })
+})

+ 51 - 0
packages/app/src/components/updater-action.ts

@@ -0,0 +1,51 @@
+import { createMemo } from "solid-js"
+import type { UpdaterState } from "@/updater"
+import { usePlatform } from "@/context/platform"
+import { useLanguage } from "@/context/language"
+import { showToast } from "@/utils/toast"
+
+export function updaterAction(state: UpdaterState | undefined) {
+  if (!state) return { label: "settings.updates.action.checkNow" as const }
+  switch (state.status) {
+    case "checking":
+      return { label: "settings.updates.action.checking" as const }
+    case "downloading":
+      return { label: "settings.updates.action.downloading" as const }
+    case "ready":
+      return { label: "toast.update.action.installRestart" as const, run: "install" as const }
+    case "installing":
+      return { label: "settings.updates.action.installing" as const }
+    case "disabled":
+      return { label: "settings.updates.action.checkNow" as const }
+    default:
+      return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
+  }
+}
+
+export function useUpdaterAction() {
+  const platform = usePlatform()
+  const language = useLanguage()
+  const action = createMemo(() => updaterAction(platform.updater?.state()))
+
+  return {
+    action,
+    async run() {
+      const run = action().run
+      if (run === "install") return platform.updater?.install()
+      if (run !== "check") return
+
+      const state = await platform.updater?.check()
+      if (state?.status === "up-to-date") {
+        showToast({
+          variant: "success",
+          icon: "circle-check",
+          title: language.t("settings.updates.toast.latest.title"),
+          description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
+        })
+      }
+      if (state?.status === "error") {
+        showToast({ title: language.t("common.requestFailed"), description: state.message })
+      }
+    },
+  }
+}

+ 3 - 6
packages/app/src/context/platform.tsx

@@ -4,12 +4,12 @@ import type { Accessor } from "solid-js"
 import type { DesktopMenuAction } from "../desktop-menu"
 import { ServerConnection } from "./server"
 import type { WslServersPlatform } from "../wsl/types"
+import type { UpdaterPlatform } from "../updater"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
 type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
 type SaveFilePickerOptions = { title?: string; defaultPath?: string }
-type UpdateInfo = { updateAvailable: boolean; version?: string }
 type PlatformName = "web" | "desktop"
 type DesktopOS = "macos" | "windows" | "linux"
 
@@ -61,11 +61,8 @@ export type Platform = {
   /** Storage mechanism, defaults to localStorage */
   storage?: (name?: string) => SyncStorage | AsyncStorage
 
-  /** Check for a downloadable desktop update */
-  checkUpdate?(): Promise<UpdateInfo>
-
-  /** Install the downloaded update using the platform restart flow */
-  updateAndRestart?(): Promise<void>
+  /** Application-global desktop updater */
+  updater?: UpdaterPlatform
 
   /** Fetch override */
   fetch?: typeof fetch

+ 0 - 12
packages/app/src/context/settings.tsx

@@ -35,9 +35,6 @@ export interface Settings {
     showCustomAgents: boolean
     newLayoutDesigns?: boolean
   }
-  updates: {
-    startup: boolean
-  }
   appearance: {
     fontSize: number
     mono: string
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
     showSessionProgressBar: true,
     showCustomAgents: false,
   },
-  updates: {
-    startup: true,
-  },
   appearance: {
     fontSize: 14,
     mono: "",
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
           setStore("general", "newLayoutDesigns", value)
         },
       },
-      updates: {
-        startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
-        setStartup(value: boolean) {
-          setStore("updates", "startup", value)
-        },
-      },
       appearance: {
         fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
         setFontSize(value: number) {

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -866,6 +866,8 @@ export const dict = {
   "settings.updates.row.check.description": "Manually check for updates and install if available",
   "settings.updates.action.checkNow": "Check now",
   "settings.updates.action.checking": "Checking...",
+  "settings.updates.action.downloading": "Downloading...",
+  "settings.updates.action.installing": "Installing...",
   "settings.updates.toast.latest.title": "You're up to date",
   "settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
   "sound.option.none": "None",

+ 7 - 1
packages/app/src/index.ts

@@ -3,7 +3,13 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from
 export { useCommand } from "./context/command"
 export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
 export { useWslServers } from "./wsl/context"
-export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
+export {
+  type DisplayBackend,
+  type FatalRendererErrorLog,
+  type Platform,
+  PlatformProvider,
+} from "./context/platform"
+export { type UpdaterPlatform, type UpdaterState } from "./updater"
 export {
   type WslDistroProbe,
   type WslInstalledDistro,

+ 23 - 25
packages/app/src/pages/error.tsx

@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
   const formattedError = () => formatError(props.error, language.t)
   let recordedFatalError: Promise<void> | undefined
   const [store, setStore] = createStore({
-    checking: false,
-    version: undefined as string | undefined,
     actionError: undefined as string | undefined,
   })
 
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
   })
 
   async function checkForUpdates() {
-    if (!platform.checkUpdate) return
-    setStore("checking", true)
-    await platform
-      .checkUpdate()
-      .then((result) => {
-        setStore("actionError", undefined)
-        if (result.updateAvailable && result.version) setStore("version", result.version)
-      })
-      .catch((err) => {
-        setStore("actionError", formatError(err, language.t))
-      })
-      .finally(() => {
-        setStore("checking", false)
-      })
+    const state = await platform.updater?.check()
+    setStore("actionError", state?.status === "error" ? state.message : undefined)
   }
 
   async function installUpdate() {
-    if (!platform.updateAndRestart) return
     await platform
-      .updateAndRestart()
+      .updater?.install()
       .then(() => setStore("actionError", undefined))
       .catch((err) => {
         setStore("actionError", formatError(err, language.t))
       })
   }
 
+  const updateVersion = () => {
+    const state = platform.updater?.state()
+    if (state?.status !== "ready") return
+    return state.version
+  }
+
   async function exportDebugLogs() {
     const exportLogs = platform.exportDebugLogs
     if (!exportLogs) return
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
               )
             }}
           </Show>
-          <Show when={platform.checkUpdate}>
+          <Show when={platform.updater}>
             <Show
-              when={store.version}
+              when={updateVersion()}
               fallback={
-                <Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
-                  {store.checking
+                <Button
+                  size="large"
+                  variant="ghost"
+                  onClick={checkForUpdates}
+                  disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
+                >
+                  {platform.updater?.state().status === "checking"
                     ? language.t("error.page.action.checking")
                     : language.t("error.page.action.checkUpdates")}
                 </Button>
               }
             >
-              <Button size="large" onClick={installUpdate}>
-                {language.t("error.page.action.updateTo", { version: store.version ?? "" })}
-              </Button>
+              {(version) => (
+                <Button size="large" onClick={installUpdate}>
+                  {language.t("error.page.action.updateTo", { version: version() })}
+                </Button>
+              )}
             </Show>
           </Show>
         </div>

+ 5 - 20
packages/app/src/pages/layout.tsx

@@ -14,7 +14,6 @@ import {
 } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { useQuery } from "@tanstack/solid-query"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useServerSync } from "@/context/server-sync"
 import { Persist, persisted } from "@/utils/persist"
@@ -90,7 +89,6 @@ import {
 } from "./layout/sidebar-workspace"
 import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
 import { SidebarContent } from "./layout/sidebar-shell"
-import { runUpdateAndRestart } from "./layout/update"
 
 export default function Layout(props: ParentProps) {
   const serverSDK = useServerSDK()
@@ -168,28 +166,15 @@ export default function Layout(props: ParentProps) {
     peeked: false,
   })
 
-  const [update, setUpdate] = createStore({
-    installing: false,
-  })
-  const updateQuery = useQuery(() => ({
-    queryKey: ["desktop", "update"] as const,
-    enabled: () =>
-      !!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
-    queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
-    refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
-  }))
   const updateVersion = () => {
-    if (!settings.ready()) return
-    if (!settings.updates.startup()) return
-    if (!updateQuery.data?.updateAvailable) return
-    return updateQuery.data.version ?? ""
-  }
-  const installUpdate = () => {
-    runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
+    const state = platform.updater?.state()
+    if (state?.status !== "ready") return
+    return state.version
   }
+  const installUpdate = () => void platform.updater?.install()
   const titlebarUpdate: TitlebarUpdate = {
     version: updateVersion,
-    installing: () => update.installing,
+    installing: () => platform.updater?.state().status === "installing",
     install: installUpdate,
   }
 

+ 0 - 19
packages/app/src/pages/layout/update.test.ts

@@ -1,19 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { runUpdateAndRestart } from "./update"
-
-describe("runUpdateAndRestart", () => {
-  test("clears the installing state when restart resolves without exiting", async () => {
-    const states: boolean[] = []
-    await new Promise<void>((resolve) => {
-      runUpdateAndRestart(
-        async () => {},
-        (installing) => {
-          states.push(installing)
-          if (states.length === 2) resolve()
-        },
-      )
-    })
-
-    expect(states).toEqual([true, false])
-  })
-})

+ 0 - 10
packages/app/src/pages/layout/update.ts

@@ -1,10 +0,0 @@
-export function runUpdateAndRestart(
-  updateAndRestart: (() => Promise<void>) | undefined,
-  setInstalling: (installing: boolean) => void,
-) {
-  if (!updateAndRestart) return
-  setInstalling(true)
-  void updateAndRestart()
-    .catch(() => undefined)
-    .finally(() => setInstalling(false))
-}

+ 17 - 0
packages/app/src/updater.ts

@@ -0,0 +1,17 @@
+import type { Accessor } from "solid-js"
+
+export type UpdaterState =
+  | { status: "disabled" }
+  | { status: "idle" }
+  | { status: "checking" }
+  | { status: "downloading"; version: string; percent?: number }
+  | { status: "ready"; version: string }
+  | { status: "up-to-date" }
+  | { status: "installing"; version: string }
+  | { status: "error"; message: string }
+
+export type UpdaterPlatform = {
+  state: Accessor<UpdaterState>
+  check(): Promise<UpdaterState>
+  install(): Promise<void>
+}

+ 16 - 15
packages/desktop/src/main/index.ts

@@ -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()

+ 27 - 11
packages/desktop/src/main/ipc.ts

@@ -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 - 0
packages/desktop/src/main/updater-controller.test.ts

@@ -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 - 0
packages/desktop/src/main/updater-controller.ts

@@ -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 - 0
packages/desktop/src/main/updater-subscriptions.test.ts

@@ -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 - 0
packages/desktop/src/main/updater-subscriptions.ts

@@ -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()
+    },
+  }
+}

+ 32 - 93
packages/desktop/src/main/updater.ts

@@ -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",
-    })
+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: "error", message: "Update check failed.", title: "Update Error" })
     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" })
+  if (state.status === "up-to-date") {
     if (!alertOnFail) return
-    await dialog.showMessageBox({
-      type: "info",
-      message: "You're up to date.",
-      title: "No Updates",
-    })
+    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()
 }

+ 29 - 4
packages/desktop/src/preload/index.ts

@@ -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),

+ 7 - 8
packages/desktop/src/preload/types.ts

@@ -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>

+ 9 - 11
packages/desktop/src/renderer/index.tsx

@@ -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(),

+ 0 - 12
packages/desktop/src/renderer/updater.ts

@@ -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"))
-    }
-  }
-}