feat(desktop): make updates persistent and responsive (#31191)
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./desktop-menu": "./src/desktop-menu.ts",
|
"./desktop-menu": "./src/desktop-menu.ts",
|
||||||
|
"./updater": "./src/updater.ts",
|
||||||
"./wsl/types": "./src/wsl/types.ts",
|
"./wsl/types": "./src/wsl/types.ts",
|
||||||
"./vite": "./vite.js",
|
"./vite": "./vite.js",
|
||||||
"./index.css": "./src/index.css"
|
"./index.css": "./src/index.css"
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
updaterEnabled?: boolean
|
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
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 { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
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 { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useUpdaterAction } from "./updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const updater = useUpdaterAction()
|
||||||
checking: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
}
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
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>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
<SettingsList>
|
<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
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
{store.checking
|
{language.t(updater.action().label)}
|
||||||
? language.t("settings.updates.action.checking")
|
|
||||||
: language.t("settings.updates.action.checkNow")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
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 { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
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 { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const updater = useUpdaterAction()
|
||||||
checking: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
}
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<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
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
{store.checking
|
{language.t(updater.action().label)}
|
||||||
? language.t("settings.updates.action.checking")
|
|
||||||
: language.t("settings.updates.action.checkNow")}
|
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import type { Accessor } from "solid-js"
|
|||||||
import type { DesktopMenuAction } from "../desktop-menu"
|
import type { DesktopMenuAction } from "../desktop-menu"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import type { WslServersPlatform } from "../wsl/types"
|
import type { WslServersPlatform } from "../wsl/types"
|
||||||
|
import type { UpdaterPlatform } from "../updater"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
|
||||||
type PlatformName = "web" | "desktop"
|
type PlatformName = "web" | "desktop"
|
||||||
type DesktopOS = "macos" | "windows" | "linux"
|
type DesktopOS = "macos" | "windows" | "linux"
|
||||||
|
|
||||||
@@ -61,11 +61,8 @@ export type Platform = {
|
|||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
/** Check for a downloadable desktop update */
|
/** Application-global desktop updater */
|
||||||
checkUpdate?(): Promise<UpdateInfo>
|
updater?: UpdaterPlatform
|
||||||
|
|
||||||
/** Install the downloaded update using the platform restart flow */
|
|
||||||
updateAndRestart?(): Promise<void>
|
|
||||||
|
|
||||||
/** Fetch override */
|
/** Fetch override */
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export interface Settings {
|
|||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
}
|
}
|
||||||
updates: {
|
|
||||||
startup: boolean
|
|
||||||
}
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
mono: string
|
mono: string
|
||||||
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
|
|||||||
showSessionProgressBar: true,
|
showSessionProgressBar: true,
|
||||||
showCustomAgents: false,
|
showCustomAgents: false,
|
||||||
},
|
},
|
||||||
updates: {
|
|
||||||
startup: true,
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
mono: "",
|
mono: "",
|
||||||
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("general", "newLayoutDesigns", value)
|
setStore("general", "newLayoutDesigns", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
updates: {
|
|
||||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
|
||||||
setStartup(value: boolean) {
|
|
||||||
setStore("updates", "startup", value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
|
|||||||
@@ -866,6 +866,8 @@ export const dict = {
|
|||||||
"settings.updates.row.check.description": "Manually check for updates and install if available",
|
"settings.updates.row.check.description": "Manually check for updates and install if available",
|
||||||
"settings.updates.action.checkNow": "Check now",
|
"settings.updates.action.checkNow": "Check now",
|
||||||
"settings.updates.action.checking": "Checking...",
|
"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.title": "You're up to date",
|
||||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||||
"sound.option.none": "None",
|
"sound.option.none": "None",
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from
|
|||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||||
export { useWslServers } from "./wsl/context"
|
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 {
|
export {
|
||||||
type WslDistroProbe,
|
type WslDistroProbe,
|
||||||
type WslInstalledDistro,
|
type WslInstalledDistro,
|
||||||
|
|||||||
@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
const formattedError = () => formatError(props.error, language.t)
|
const formattedError = () => formatError(props.error, language.t)
|
||||||
let recordedFatalError: Promise<void> | undefined
|
let recordedFatalError: Promise<void> | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
|
||||||
version: undefined as string | undefined,
|
|
||||||
actionError: undefined as string | undefined,
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
const state = await platform.updater?.check()
|
||||||
setStore("checking", true)
|
setStore("actionError", state?.status === "error" ? state.message : undefined)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installUpdate() {
|
async function installUpdate() {
|
||||||
if (!platform.updateAndRestart) return
|
|
||||||
await platform
|
await platform
|
||||||
.updateAndRestart()
|
.updater?.install()
|
||||||
.then(() => setStore("actionError", undefined))
|
.then(() => setStore("actionError", undefined))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setStore("actionError", formatError(err, language.t))
|
setStore("actionError", formatError(err, language.t))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateVersion = () => {
|
||||||
|
const state = platform.updater?.state()
|
||||||
|
if (state?.status !== "ready") return
|
||||||
|
return state.version
|
||||||
|
}
|
||||||
|
|
||||||
async function exportDebugLogs() {
|
async function exportDebugLogs() {
|
||||||
const exportLogs = platform.exportDebugLogs
|
const exportLogs = platform.exportDebugLogs
|
||||||
if (!exportLogs) return
|
if (!exportLogs) return
|
||||||
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={platform.checkUpdate}>
|
<Show when={platform.updater}>
|
||||||
<Show
|
<Show
|
||||||
when={store.version}
|
when={updateVersion()}
|
||||||
fallback={
|
fallback={
|
||||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
<Button
|
||||||
{store.checking
|
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.checking")
|
||||||
: language.t("error.page.action.checkUpdates")}
|
: language.t("error.page.action.checkUpdates")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button size="large" onClick={installUpdate}>
|
{(version) => (
|
||||||
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
|
<Button size="large" onClick={installUpdate}>
|
||||||
</Button>
|
{language.t("error.page.action.updateTo", { version: version() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -90,7 +89,6 @@ import {
|
|||||||
} from "./layout/sidebar-workspace"
|
} from "./layout/sidebar-workspace"
|
||||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||||
import { SidebarContent } from "./layout/sidebar-shell"
|
import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
import { runUpdateAndRestart } from "./layout/update"
|
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -168,28 +166,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
peeked: false,
|
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 = () => {
|
const updateVersion = () => {
|
||||||
if (!settings.ready()) return
|
const state = platform.updater?.state()
|
||||||
if (!settings.updates.startup()) return
|
if (state?.status !== "ready") return
|
||||||
if (!updateQuery.data?.updateAvailable) return
|
return state.version
|
||||||
return updateQuery.data.version ?? ""
|
|
||||||
}
|
|
||||||
const installUpdate = () => {
|
|
||||||
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
|
|
||||||
}
|
}
|
||||||
|
const installUpdate = () => void platform.updater?.install()
|
||||||
const titlebarUpdate: TitlebarUpdate = {
|
const titlebarUpdate: TitlebarUpdate = {
|
||||||
version: updateVersion,
|
version: updateVersion,
|
||||||
installing: () => update.installing,
|
installing: () => platform.updater?.state().status === "installing",
|
||||||
install: installUpdate,
|
install: installUpdate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import contextMenu from "electron-context-menu"
|
|||||||
|
|
||||||
import type { ServerReadyData } from "../preload/types"
|
import type { ServerReadyData } from "../preload/types"
|
||||||
import { checkAppExists, resolveAppPath } from "./apps"
|
import { checkAppExists, resolveAppPath } from "./apps"
|
||||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
import { CHANNEL } from "./constants"
|
||||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
||||||
import { forwardInitializationFailure } from "./initialization"
|
import { forwardInitializationFailure } from "./initialization"
|
||||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
spawnLocalServer,
|
spawnLocalServer,
|
||||||
type SidecarListener,
|
type SidecarListener,
|
||||||
} from "./server"
|
} from "./server"
|
||||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||||
import {
|
import {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
registerRendererProtocol,
|
registerRendererProtocol,
|
||||||
@@ -232,6 +232,13 @@ const main = Effect.gen(function* () {
|
|||||||
|
|
||||||
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
|
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({
|
registerIpcHandlers({
|
||||||
killSidecar: () => killSidecar(),
|
killSidecar: () => killSidecar(),
|
||||||
relaunch,
|
relaunch,
|
||||||
@@ -244,7 +251,6 @@ const main = Effect.gen(function* () {
|
|||||||
},
|
},
|
||||||
(e) => Effect.runPromise(e),
|
(e) => Effect.runPromise(e),
|
||||||
),
|
),
|
||||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
|
||||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||||
@@ -253,22 +259,17 @@ const main = Effect.gen(function* () {
|
|||||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||||
checkAppExists: (appName) => checkAppExists(appName),
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
|
updater,
|
||||||
checkUpdate: async () => checkUpdate(),
|
showUpdater: () => showUpdaterDialog(updater, true),
|
||||||
installUpdate: async () => installUpdate(stopSidecars),
|
|
||||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||||
exportDebugLogs: () => exportDebugLogs(),
|
exportDebugLogs: () => exportDebugLogs(),
|
||||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||||
})
|
})
|
||||||
registerWslIpcHandlers(wslServers)
|
registerWslIpcHandlers(wslServers)
|
||||||
|
void updater.start()
|
||||||
yield* Effect.promise(() => app.whenReady())
|
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||||
|
updateTimer.unref()
|
||||||
if (!TEST_ONBOARDING) migrate()
|
app.once("will-quit", () => clearInterval(updateTimer))
|
||||||
app.setAsDefaultProtocolClient("opencode")
|
|
||||||
registerRendererProtocol()
|
|
||||||
setDockIcon()
|
|
||||||
setupAutoUpdater()
|
|
||||||
yield* Effect.promise(() => startNetLog()).pipe(
|
yield* Effect.promise(() => startNetLog()).pipe(
|
||||||
Effect.catch((error) =>
|
Effect.catch((error) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -350,7 +351,7 @@ const main = Effect.gen(function* () {
|
|||||||
if (win) sendMenuCommand(win, id)
|
if (win) sendMenuCommand(win, id)
|
||||||
},
|
},
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
void checkForUpdates(true, stopSidecars)
|
void showUpdaterDialog(updater, true)
|
||||||
},
|
},
|
||||||
relaunch: () => {
|
relaunch: () => {
|
||||||
relaunch()
|
relaunch()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { execFile } from "node:child_process"
|
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 { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
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 { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||||
|
import type { UpdaterController } from "./updater-controller"
|
||||||
|
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||||
|
|
||||||
const pickerFilters = (ext?: string[]) => {
|
const pickerFilters = (ext?: string[]) => {
|
||||||
if (!ext || ext.length === 0) return undefined
|
if (!ext || ext.length === 0) return undefined
|
||||||
@@ -17,7 +19,6 @@ type Deps = {
|
|||||||
killSidecar: () => Promise<void> | void
|
killSidecar: () => Promise<void> | void
|
||||||
relaunch: () => void
|
relaunch: () => void
|
||||||
awaitInitialization: () => Promise<ServerReadyData>
|
awaitInitialization: () => Promise<ServerReadyData>
|
||||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
|
||||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||||
@@ -26,18 +27,19 @@ type Deps = {
|
|||||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||||
resolveAppPath: (appName: string) => Promise<string | null>
|
resolveAppPath: (appName: string) => Promise<string | null>
|
||||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
updater: UpdaterController
|
||||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
showUpdater: () => Promise<void> | void
|
||||||
installUpdate: () => Promise<void> | void
|
|
||||||
setBackgroundColor: (color: string) => void
|
setBackgroundColor: (color: string) => void
|
||||||
exportDebugLogs: () => Promise<string>
|
exportDebugLogs: () => Promise<string>
|
||||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: Deps) {
|
export function registerIpcHandlers(deps: Deps) {
|
||||||
|
const updaterSubscriptions = createUpdaterSubscriptions()
|
||||||
|
app.once("will-quit", updaterSubscriptions.clear)
|
||||||
|
|
||||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||||
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
|
||||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
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("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
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("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||||
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
ipcMain.handle("updater-subscribe", (event) => {
|
||||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
const id = event.sender.id
|
||||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
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("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||||
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||||
@@ -191,7 +204,10 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
setTitlebar(win, theme)
|
setTitlebar(win, theme)
|
||||||
})
|
})
|
||||||
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
|
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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { app, dialog } from "electron"
|
||||||
import pkg from "electron-updater"
|
import pkg from "electron-updater"
|
||||||
import { UPDATER_ENABLED } from "./constants"
|
import { UPDATER_ENABLED } from "./constants"
|
||||||
|
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||||
import { getLogger } from "./logging"
|
import { getLogger } from "./logging"
|
||||||
|
import { getStore } from "./store"
|
||||||
|
|
||||||
const { autoUpdater } = pkg
|
const { autoUpdater } = pkg
|
||||||
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
|
const key = "ready"
|
||||||
let downloadedVersion: string | undefined
|
|
||||||
let pendingCheck: Promise<UpdateCheckResult> | undefined
|
|
||||||
|
|
||||||
export function setupAutoUpdater() {
|
export function setupAutoUpdater(stop: () => Promise<void>) {
|
||||||
if (!UPDATER_ENABLED) return
|
|
||||||
const logger = getLogger()
|
const logger = getLogger()
|
||||||
autoUpdater.logger = logger
|
autoUpdater.logger = logger
|
||||||
autoUpdater.channel = "latest"
|
autoUpdater.channel = "latest"
|
||||||
@@ -23,110 +22,50 @@ export function setupAutoUpdater() {
|
|||||||
allowDowngrade: autoUpdater.allowDowngrade,
|
allowDowngrade: autoUpdater.allowDowngrade,
|
||||||
currentVersion: app.getVersion(),
|
currentVersion: app.getVersion(),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkUpdate(): Promise<UpdateCheckResult> {
|
const store = getStore("opencode.updater")
|
||||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
return createUpdaterController({
|
||||||
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
|
enabled: UPDATER_ENABLED,
|
||||||
if (pendingCheck) return pendingCheck
|
|
||||||
|
|
||||||
pendingCheck = checkAndDownloadUpdate().finally(() => {
|
|
||||||
pendingCheck = undefined
|
|
||||||
})
|
|
||||||
return pendingCheck
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
|
|
||||||
const logger = getLogger()
|
|
||||||
logger.log("checking for updates", {
|
|
||||||
currentVersion: app.getVersion(),
|
currentVersion: app.getVersion(),
|
||||||
channel: autoUpdater.channel,
|
backend: autoUpdater,
|
||||||
allowPrerelease: autoUpdater.allowPrerelease,
|
persistence: {
|
||||||
allowDowngrade: autoUpdater.allowDowngrade,
|
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>) {
|
export async function showUpdaterDialog(
|
||||||
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
|
controller: ReturnType<typeof setupAutoUpdater>,
|
||||||
const logger = getLogger()
|
alertOnFail: boolean,
|
||||||
if (!result.updateAvailable || !downloadedVersion) {
|
) {
|
||||||
logger.log("install update skipped", {
|
const state = await controller.check()
|
||||||
reason: result.failed ? "update check failed" : "no update available",
|
if (state.status === "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 (!alertOnFail) return
|
if (!alertOnFail) return
|
||||||
await dialog.showMessageBox({
|
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
|
||||||
type: "info",
|
|
||||||
message: "You're up to date.",
|
|
||||||
title: "No Updates",
|
|
||||||
})
|
|
||||||
return
|
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({
|
const response = await dialog.showMessageBox({
|
||||||
type: "info",
|
type: "info",
|
||||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
message: `Update ${state.version} downloaded. Restart now?`,
|
||||||
title: "Update Ready",
|
title: "Update Ready",
|
||||||
buttons: ["Restart", "Later"],
|
buttons: ["Restart", "Later"],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
})
|
})
|
||||||
logger.log("update prompt response", {
|
if (response.response === 0) await controller.install()
|
||||||
version: result.version ?? null,
|
|
||||||
restartNow: response.response === 0,
|
|
||||||
})
|
|
||||||
if (response.response === 0) {
|
|
||||||
await installUpdate(killSidecar)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron"
|
import { contextBridge, ipcRenderer } from "electron"
|
||||||
import type { ElectronAPI, WslServersEvent } from "./types"
|
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 = {
|
const api: ElectronAPI = {
|
||||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||||
@@ -28,7 +37,26 @@ const api: ElectronAPI = {
|
|||||||
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
||||||
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", 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"),
|
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||||
@@ -83,9 +111,6 @@ const api: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
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),
|
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||||
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
||||||
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||||
|
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||||
export type {
|
export type {
|
||||||
WslDistroProbe,
|
WslDistroProbe,
|
||||||
WslInstalledDistro,
|
WslInstalledDistro,
|
||||||
@@ -21,15 +22,16 @@ export type ServerReadyData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WslServersAPI = WslServersPlatform
|
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 LinuxDisplayBackend = "wayland" | "auto"
|
||||||
export type TitlebarTheme = {
|
export type TitlebarTheme = {
|
||||||
mode: "light" | "dark"
|
mode: "light" | "dark"
|
||||||
}
|
}
|
||||||
export type WindowConfig = {
|
|
||||||
updaterEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FatalRendererError = {
|
export type FatalRendererError = {
|
||||||
error: string
|
error: string
|
||||||
url: string
|
url: string
|
||||||
@@ -43,7 +45,7 @@ export type ElectronAPI = {
|
|||||||
installCli: () => Promise<string>
|
installCli: () => Promise<string>
|
||||||
awaitInitialization: () => Promise<ServerReadyData>
|
awaitInitialization: () => Promise<ServerReadyData>
|
||||||
wslServers: WslServersAPI
|
wslServers: WslServersAPI
|
||||||
getWindowConfig: () => Promise<WindowConfig>
|
updater: UpdaterAPI
|
||||||
consumeInitialDeepLinks: () => Promise<string[]>
|
consumeInitialDeepLinks: () => Promise<string[]>
|
||||||
getDefaultServerUrl: () => Promise<string | null>
|
getDefaultServerUrl: () => Promise<string | null>
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||||
@@ -92,9 +94,6 @@ export type ElectronAPI = {
|
|||||||
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
||||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||||
runDesktopMenuAction: (action: DesktopMenuAction) => 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>
|
setBackgroundColor: (color: string) => Promise<void>
|
||||||
exportDebugLogs: () => Promise<string>
|
exportDebugLogs: () => Promise<string>
|
||||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
useCommand,
|
useCommand,
|
||||||
useWslServers,
|
useWslServers,
|
||||||
} from "@opencode-ai/app"
|
} from "@opencode-ai/app"
|
||||||
|
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||||
import { MemoryRouter } from "@solidjs/router"
|
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 { render } from "solid-js/web"
|
||||||
import pkg from "../../package.json"
|
import pkg from "../../package.json"
|
||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
@@ -59,6 +60,9 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
|||||||
|
|
||||||
void initI18n()
|
void initI18n()
|
||||||
|
|
||||||
|
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
|
||||||
|
void window.api.updater.subscribe(setUpdaterState)
|
||||||
|
|
||||||
const deepLinkEvent = "opencode:deep-link"
|
const deepLinkEvent = "opencode:deep-link"
|
||||||
|
|
||||||
const emitDeepLinks = (urls: string[]) => {
|
const emitDeepLinks = (urls: string[]) => {
|
||||||
@@ -177,16 +181,10 @@ const createPlatform = (): Platform => {
|
|||||||
|
|
||||||
storage,
|
storage,
|
||||||
|
|
||||||
checkUpdate: async () => {
|
updater: {
|
||||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
state: updaterState,
|
||||||
if (!config.updaterEnabled) return { updateAvailable: false }
|
check: () => window.api.updater.check(),
|
||||||
return window.api.checkUpdate()
|
install: () => window.api.updater.install(),
|
||||||
},
|
|
||||||
|
|
||||||
updateAndRestart: async () => {
|
|
||||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
|
||||||
if (!config.updaterEnabled) return
|
|
||||||
await window.api.installUpdate()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
exportDebugLogs: () => window.api.exportDebugLogs(),
|
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user