diff --git a/apps/app/pr/updater-capability-gate/updates-desktop-before.png b/apps/app/pr/updater-capability-gate/updates-desktop-before.png new file mode 100644 index 000000000..b23dc7ab9 Binary files /dev/null and b/apps/app/pr/updater-capability-gate/updates-desktop-before.png differ diff --git a/apps/app/pr/updater-capability-gate/updates-electron-dev-after-window.png b/apps/app/pr/updater-capability-gate/updates-electron-dev-after-window.png new file mode 100644 index 000000000..69675088f Binary files /dev/null and b/apps/app/pr/updater-capability-gate/updates-electron-dev-after-window.png differ diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index efea35124..afcb75e5c 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -23,11 +23,13 @@ declare global { channel: "stable" | "alpha"; feedUrl: string; currentVersion: string; + updateChecksSupported?: boolean; }>; setChannel?: (channel: "stable" | "alpha") => Promise<{ channel: "stable" | "alpha"; feedUrl: string; currentVersion: string; + updateChecksSupported?: boolean; }>; check?: () => Promise<{ available: boolean; @@ -37,6 +39,7 @@ declare global { releaseNotes?: unknown; channel?: "stable" | "alpha"; feedUrl?: string; + updateChecksSupported?: boolean; reason?: string; }>; download?: () => Promise<{ ok: boolean; reason?: string }>; diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index 12b9d68e3..3a96faf68 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -1656,9 +1656,12 @@ export default { "settings.update_ready_version": "A punt per instal·lar: v{version}", "settings.update_uptodate": "Al dia", "settings.updates": "Actualitzacions", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Mantén OpenWork al dia.", "settings.updates_desktop_only": "Les actualitzacions només estan disponibles a l'app d'escriptori.", "settings.updates_not_supported": "Les actualitzacions no són compatibles amb aquest entorn.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Actualitzacions", "settings.version": "Versió", "settings.versions_desc": "Informació del sidecar i de la build d'escriptori.", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 166391e77..fe74e109c 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -1750,9 +1750,12 @@ export default { "settings.update_ready_version": "Ready to install: v{version}", "settings.update_uptodate": "Up to date", "settings.updates": "Updates", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Keep OpenWork up to date.", "settings.updates_desktop_only": "Updates are only available in the desktop app.", "settings.updates_not_supported": "Updates are not supported in this environment.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Updates", "settings.version": "Version", "settings.versions_desc": "Sidecar + desktop build info.", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index da84f9ec7..5f997cc59 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -1656,9 +1656,12 @@ export default { "settings.update_ready_version": "Listo para instalar: v{version}", "settings.update_uptodate": "Al día", "settings.updates": "Actualizaciones", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Mantén OpenWork actualizado.", "settings.updates_desktop_only": "Las actualizaciones solo están disponibles en la app de escritorio.", "settings.updates_not_supported": "Las actualizaciones no son compatibles en este entorno.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Actualizaciones", "settings.version": "Versión", "settings.versions_desc": "Información de versión de los sidecars y la app de escritorio.", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index f79a39fc0..6f9705df0 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -1656,9 +1656,12 @@ export default { "settings.update_ready_version": "Prêt à installer : v{version}", "settings.update_uptodate": "À jour", "settings.updates": "Mises à jour", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Gardez OpenWork à jour.", "settings.updates_desktop_only": "Les mises à jour ne sont disponibles que dans l'application desktop.", "settings.updates_not_supported": "Les mises à jour ne sont pas prises en charge dans cet environnement.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Mises à jour", "settings.version": "Version", "settings.versions_desc": "Infos de build sidecar + desktop.", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index f22e9cdd7..a65257293 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -1638,9 +1638,12 @@ export default { "settings.update_ready_version": "インストール準備完了: v{version}", "settings.update_uptodate": "最新です", "settings.updates": "アップデート", + "settings.updates_bridge_unavailable": "アップデートブリッジを利用できません。OpenWorkを再起動してもう一度お試しください。", + "settings.updates_checking_support": "アップデート対応状況を確認しています...", "settings.updates_desc": "OpenWorkを最新の状態に保ちます。", "settings.updates_desktop_only": "アップデートはデスクトップアプリでのみ利用可能です。", "settings.updates_not_supported": "この環境ではアップデートはサポートされていません。", + "settings.updates_packaged_only": "アップデート確認はパッケージ版のデスクトップビルドでのみ利用できます。", "settings.updates_title": "アップデート", "settings.version": "バージョン", "settings.versions_desc": "サイドカーとデスクトップのビルド情報。", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 96e94bddf..65830d900 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -1639,9 +1639,12 @@ export default { "settings.update_ready_version": "Pronto para instalar: v{version}", "settings.update_uptodate": "Atualizado", "settings.updates": "Atualizações", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Manter o OpenWork atualizado.", "settings.updates_desktop_only": "Atualizações estão disponíveis apenas no app desktop.", "settings.updates_not_supported": "Atualizações não são suportadas neste ambiente.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Atualizações", "settings.version": "Versão", "settings.versions_desc": "Informações de build do Sidecar + desktop.", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 94ac119a1..dcbfa9b97 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -1639,9 +1639,12 @@ export default { "settings.update_ready_version": "พร้อมติดตั้ง: v{version}", "settings.update_uptodate": "เป็นเวอร์ชันล่าสุดแล้ว", "settings.updates": "การอัปเดต", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "อัปเดต OpenWork ให้เป็นเวอร์ชันล่าสุด", "settings.updates_desktop_only": "การอัปเดตใช้งานได้เฉพาะในแอปเดสก์ท็อป", "settings.updates_not_supported": "ไม่รองรับการอัปเดตในสภาพแวดล้อมนี้", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "การอัปเดต", "settings.version": "เวอร์ชัน", "settings.versions_desc": "ข้อมูล build ของ Sidecar + เดสก์ท็อป", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 1076b9ebc..7743414b4 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -1639,9 +1639,12 @@ export default { "settings.update_ready_version": "Sẵn sàng cài đặt: v{version}", "settings.update_uptodate": "Đã cập nhật mới nhất", "settings.updates": "Cập nhật", + "settings.updates_bridge_unavailable": "Updater bridge is unavailable. Restart OpenWork and try again.", + "settings.updates_checking_support": "Checking update support...", "settings.updates_desc": "Giữ OpenWork luôn cập nhật.", "settings.updates_desktop_only": "Cập nhật chỉ khả dụng trong ứng dụng desktop.", "settings.updates_not_supported": "Cập nhật không được hỗ trợ trong môi trường này.", + "settings.updates_packaged_only": "Update checks are available only in packaged desktop builds.", "settings.updates_title": "Cập nhật", "settings.version": "Phiên bản", "settings.versions_desc": "Thông tin build sidecar + desktop.", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 3418fb564..e24133c56 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -1642,9 +1642,12 @@ export default { "settings.update_ready_version": "准备安装:v{version}", "settings.update_uptodate": "已是最新", "settings.updates": "更新", + "settings.updates_bridge_unavailable": "更新桥接不可用。请重启 OpenWork 后再试。", + "settings.updates_checking_support": "正在检查更新支持...", "settings.updates_desc": "保持OpenWork为最新版本。", "settings.updates_desktop_only": "更新仅在桌面应用中可用。", "settings.updates_not_supported": "此环境不支持更新。", + "settings.updates_packaged_only": "更新检查仅在打包后的桌面版本中可用。", "settings.updates_title": "更新", "settings.version": "版本", "settings.versions_desc": "Sidecar + 桌面版构建信息。", diff --git a/apps/app/src/react-app/domains/settings/pages/updates-view.tsx b/apps/app/src/react-app/domains/settings/pages/updates-view.tsx index 49c7bafbe..a97cba444 100644 --- a/apps/app/src/react-app/domains/settings/pages/updates-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/updates-view.tsx @@ -3,7 +3,7 @@ import { formatBytes, formatRelativeTime, isTauriRuntime } from "../../../../app import { t } from "../../../../i18n"; import type { ReleaseChannel } from "../../../../app/types"; import { Button } from "../../../design-system/button"; -import type { SettingsUpdateStatus } from "../state/electron-updater-state"; +import type { SettingsUpdateEnv, SettingsUpdateStatus } from "../state/electron-updater-state"; const settingsPanelClass = "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6"; @@ -11,7 +11,7 @@ export type UpdatesViewProps = { busy: boolean; webDeployment: boolean; appVersion: string | null; - updateEnv: { supported?: boolean; reason?: string | null } | null; + updateEnv: SettingsUpdateEnv; updateAutoCheck: boolean; toggleUpdateAutoCheck: () => void; updateAutoDownload: boolean; @@ -67,6 +67,10 @@ export function UpdatesView(props: UpdatesViewProps) {
{t("settings.updates_desktop_only")}
+ ) : props.updateEnv === null ? ( +
+ {t("settings.updates_checking_support")} +
) : props.updateEnv && props.updateEnv.supported === false ? (
{props.updateEnv.reason ?? t("settings.updates_not_supported")} diff --git a/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts b/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts index fd34e55c1..38504a1c9 100644 --- a/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts +++ b/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts @@ -5,6 +5,7 @@ import type { DenDesktopConfig } from "../../../../app/lib/den"; import { isAlphaUpdateAllowed, isUpdateAllowed } from "../../../../app/lib/version-gate"; import type { ReleaseChannel } from "../../../../app/types"; import { isElectronRuntime, isTauriRuntime, safeStringify } from "../../../../app/utils"; +import { t } from "../../../../i18n"; export type SettingsUpdateStatus = { state: "idle" | "checking" | "available" | "downloading" | "ready" | "error"; @@ -20,6 +21,14 @@ export type SettingsUpdateStatus = { type ElectronUpdaterBridge = NonNullable["updater"] & { onDownloadProgress?: (callback: (data: { transferred: number; total: number; percent: number; bytesPerSecond: number }) => void) => (() => void); }; +type ElectronUpdaterMethod = "getChannel" | "setChannel" | "check" | "download" | "installAndRestart"; +type CompleteElectronUpdaterBridge = ElectronUpdaterBridge & Required>; +type ElectronUpdaterChannelState = { + channel?: ReleaseChannel; + currentVersion?: string | null; + updateChecksSupported?: boolean; + reason?: string | null; +}; type TauriUpdate = { version?: string; date?: string; @@ -27,6 +36,18 @@ type TauriUpdate = { downloadAndInstall?: (handler?: (event: unknown) => void) => Promise; }; +export type SettingsUpdateEnv = { supported?: boolean; reason?: string | null } | null; + +const ELECTRON_UPDATER_REQUIRED_METHODS: readonly ElectronUpdaterMethod[] = [ + "getChannel", + "setChannel", + "check", + "download", + "installAndRestart", +]; +const ELECTRON_UPDATER_BRIDGE_WAIT_ATTEMPTS = 8; +const ELECTRON_UPDATER_BRIDGE_WAIT_MS = 25; + type UseElectronUpdaterStateOptions = { releaseChannel: ReleaseChannel; onReleaseChannelChange: (next: ReleaseChannel) => void; @@ -40,6 +61,45 @@ function electronUpdaterBridge(): ElectronUpdaterBridge | null { return window.__OPENWORK_ELECTRON__?.updater ?? null; } +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function waitForElectronUpdaterBridge() { + for (let attempt = 0; attempt < ELECTRON_UPDATER_BRIDGE_WAIT_ATTEMPTS; attempt += 1) { + const bridge = electronUpdaterBridge(); + if (bridge) return bridge; + await delay(ELECTRON_UPDATER_BRIDGE_WAIT_MS); + } + return electronUpdaterBridge(); +} + +export function missingElectronUpdaterMethods( + bridge: ElectronUpdaterBridge | null | undefined, +): ElectronUpdaterMethod[] { + if (!bridge) return [...ELECTRON_UPDATER_REQUIRED_METHODS]; + const values = bridge as Record; + return ELECTRON_UPDATER_REQUIRED_METHODS.filter((method) => typeof values[method] !== "function"); +} + +function hasCompleteElectronUpdaterBridge( + bridge: ElectronUpdaterBridge | null | undefined, +): bridge is CompleteElectronUpdaterBridge { + return missingElectronUpdaterMethods(bridge).length === 0; +} + +export function electronUpdaterRequiresPackagedBuild(state: ElectronUpdaterChannelState | null | undefined) { + return state?.updateChecksSupported === false || state?.reason === "unavailable"; +} + +function updaterBridgeUnavailableMessage() { + return t("settings.updates_bridge_unavailable"); +} + +function updaterPackagedOnlyMessage() { + return t("settings.updates_packaged_only"); +} + function describeError(error: unknown) { if (error instanceof Error) return error.message; const serialized = safeStringify(error); @@ -78,12 +138,23 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) const { releaseChannel, onReleaseChannelChange, updateAutoDownload, desktopConfig, setError } = options; const [updateStatus, setUpdateStatus] = useState(null); const [appVersion, setAppVersion] = useState(null); - const [updateEnv, setUpdateEnv] = useState<{ supported?: boolean; reason?: string | null } | null>(null); + const [updateEnv, setUpdateEnv] = useState(null); + const [electronReleaseChannelSupported, setElectronReleaseChannelSupported] = useState(false); const tauriUpdateRef = useRef(null); + const releaseChannelRef = useRef(releaseChannel); + const onReleaseChannelChangeRef = useRef(onReleaseChannelChange); + + // Probe the native updater bridge once; channel changes go through setReleaseChannel. + useEffect(() => { + releaseChannelRef.current = releaseChannel; + onReleaseChannelChangeRef.current = onReleaseChannelChange; + }, [onReleaseChannelChange, releaseChannel]); useEffect(() => { if (isTauriRuntime()) { let cancelled = false; + setUpdateEnv({ supported: true, reason: null }); + setElectronReleaseChannelSupported(false); void import("@tauri-apps/api/app") .then(({ getVersion }) => getVersion()) .then((version) => { @@ -95,36 +166,56 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) }; } - if (!isElectronRuntime()) return; - const bridge = electronUpdaterBridge(); - if (!bridge?.getChannel) { - setUpdateEnv({ supported: false, reason: "Electron updater bridge is unavailable." }); + if (!isElectronRuntime()) { + setUpdateEnv({ supported: false, reason: t("settings.updates_desktop_only") }); + setElectronReleaseChannelSupported(false); return; } + let cancelled = false; - void bridge - .getChannel() - .then(async (state) => { + void waitForElectronUpdaterBridge() + .then(async (bridge) => { + if (cancelled) return; + if (!hasCompleteElectronUpdaterBridge(bridge)) { + setUpdateEnv({ supported: false, reason: updaterBridgeUnavailableMessage() }); + setElectronReleaseChannelSupported(false); + return; + } + + const state = await bridge.getChannel(); if (cancelled) return; setAppVersion(state.currentVersion ?? null); - if (state.channel && state.channel !== releaseChannel && bridge.setChannel) { - const nextState = await bridge.setChannel(releaseChannel); + if (electronUpdaterRequiresPackagedBuild(state)) { + setUpdateEnv({ supported: false, reason: updaterPackagedOnlyMessage() }); + setElectronReleaseChannelSupported(false); + return; + } + setUpdateEnv({ supported: true, reason: null }); + setElectronReleaseChannelSupported(true); + if (state.channel && state.channel !== releaseChannelRef.current) { + const nextState = await bridge.setChannel(releaseChannelRef.current); if (cancelled) return; setAppVersion(nextState.currentVersion ?? null); - if (nextState.channel && nextState.channel !== releaseChannel) { - onReleaseChannelChange(nextState.channel); + if (electronUpdaterRequiresPackagedBuild(nextState)) { + setUpdateEnv({ supported: false, reason: updaterPackagedOnlyMessage() }); + setElectronReleaseChannelSupported(false); + return; + } + if (nextState.channel && nextState.channel !== releaseChannelRef.current) { + onReleaseChannelChangeRef.current(nextState.channel); } } }) .catch(() => { if (!cancelled) { - setUpdateEnv({ supported: false, reason: "Electron updater bridge is unavailable." }); + setUpdateEnv({ supported: false, reason: updaterBridgeUnavailableMessage() }); + setElectronReleaseChannelSupported(false); } }); return () => { cancelled = true; }; - }, [onReleaseChannelChange, releaseChannel]); + }, []); const downloadUpdate = useCallback(async () => { if (isTauriRuntime()) { @@ -178,7 +269,7 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) const bridge = electronUpdaterBridge(); if (!bridge?.download) { - const message = "Electron updater downloads are available only in the Electron desktop app."; + const message = updaterBridgeUnavailableMessage(); setUpdateStatus({ state: "error", message }); setError(message); return; @@ -207,7 +298,12 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) try { const result = await bridge.download(); if (!result?.ok) { - setUpdateStatus({ state: "error", message: result?.reason ?? "Update download failed." }); + setUpdateStatus({ + state: "error", + message: result?.reason === "unavailable" + ? updaterPackagedOnlyMessage() + : (result?.reason ?? "Update download failed."), + }); return; } setUpdateStatus((current) => ({ @@ -257,7 +353,7 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) const bridge = electronUpdaterBridge(); if (!bridge?.check) { - const message = "Electron update checks are available only in the Electron desktop app."; + const message = updaterBridgeUnavailableMessage(); setUpdateStatus({ state: "error", message }); setError(message); return; @@ -270,11 +366,11 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) if (result.channel && result.channel !== releaseChannel) { onReleaseChannelChange(result.channel); } - if (result.reason === "unavailable") { - setUpdateStatus({ - state: "idle", - message: "Auto-updates are available in packaged builds only.", - }); + if (electronUpdaterRequiresPackagedBuild(result)) { + const message = updaterPackagedOnlyMessage(); + setUpdateEnv({ supported: false, reason: message }); + setElectronReleaseChannelSupported(false); + setUpdateStatus({ state: "error", message }); return; } if (result.reason) { @@ -324,40 +420,58 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) const bridge = electronUpdaterBridge(); if (!bridge?.installAndRestart) { - const message = "Electron update install is available only in the Electron desktop app."; + const message = updaterBridgeUnavailableMessage(); setUpdateStatus({ state: "error", message }); setError(message); return; } const result = await bridge.installAndRestart(); if (!result?.ok) { - setUpdateStatus({ state: "error", message: result?.reason ?? "Update install failed." }); + setUpdateStatus({ + state: "error", + message: result?.reason === "unavailable" + ? updaterPackagedOnlyMessage() + : (result?.reason ?? "Update install failed."), + }); } }, [setError]); const setReleaseChannel = useCallback( async (next: ReleaseChannel) => { - onReleaseChannelChange(next); const bridge = electronUpdaterBridge(); - if (!bridge?.setChannel) return; + if (!bridge?.setChannel) { + const message = updaterBridgeUnavailableMessage(); + setUpdateStatus({ state: "error", message }); + setError(message); + return; + } try { const state = await bridge.setChannel(next); setAppVersion(state.currentVersion ?? null); - if (state.channel && state.channel !== next) { - onReleaseChannelChange(state.channel); + if (electronUpdaterRequiresPackagedBuild(state)) { + const message = updaterPackagedOnlyMessage(); + setUpdateEnv({ supported: false, reason: message }); + setElectronReleaseChannelSupported(false); + setUpdateStatus({ state: "error", message }); + return; } + const resolvedChannel = state.channel ?? next; + onReleaseChannelChange(resolvedChannel); + setUpdateEnv({ supported: true, reason: null }); + setElectronReleaseChannelSupported(true); setUpdateStatus({ state: "idle", lastCheckedAt: null }); } catch (error) { setUpdateStatus({ state: "error", message: describeError(error) }); } }, - [onReleaseChannelChange], + [onReleaseChannelChange, setError], ); return { appVersion, updateEnv, updateStatus, + electronReleaseChannelSupported, checkForUpdates, downloadUpdate, installUpdateAndRestart, diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 122a36aec..a8c707e51 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -60,7 +60,7 @@ import { import { isDesktopProviderBlocked } from "../../app/cloud/desktop-app-restrictions"; import { useCheckDesktopRestriction, useDesktopConfig } from "../domains/cloud/desktop-config-provider"; import { useCloudProviderAutoSync } from "../domains/cloud/use-cloud-provider-auto-sync"; -import { isDesktopRuntime, isElectronRuntime, isMacPlatform, normalizeDirectoryPath, safeStringify } from "../../app/utils"; +import { isDesktopRuntime, isMacPlatform, normalizeDirectoryPath, safeStringify } from "../../app/utils"; import { CreateWorkspaceModal } from "../domains/workspace/create-workspace-modal"; import { ModelPickerModal } from "../domains/session/modals/model-picker-modal"; import type { ModelOption, ModelRef } from "../../app/types"; @@ -1271,7 +1271,7 @@ export function SettingsRoute() { installUpdateAndRestart={electronUpdaterState.installUpdateAndRestart} releaseChannel={local.prefs.releaseChannel ?? "stable"} onReleaseChannelChange={electronUpdaterState.setReleaseChannel} - alphaChannelSupported={isElectronRuntime() && isMacPlatform()} + alphaChannelSupported={electronUpdaterState.electronReleaseChannelSupported && isMacPlatform()} /> ); case "recovery": diff --git a/apps/app/tests/updater-capability.test.ts b/apps/app/tests/updater-capability.test.ts new file mode 100644 index 000000000..5b54766aa --- /dev/null +++ b/apps/app/tests/updater-capability.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + electronUpdaterRequiresPackagedBuild, + missingElectronUpdaterMethods, +} from "../src/react-app/domains/settings/state/electron-updater-state"; +import { UpdatesView } from "../src/react-app/domains/settings/pages/updates-view"; +import ca from "../src/i18n/locales/ca"; +import en from "../src/i18n/locales/en"; +import es from "../src/i18n/locales/es"; +import fr from "../src/i18n/locales/fr"; +import ja from "../src/i18n/locales/ja"; +import ptBR from "../src/i18n/locales/pt-BR"; +import th from "../src/i18n/locales/th"; +import vi from "../src/i18n/locales/vi"; +import zh from "../src/i18n/locales/zh"; + +const noop = () => {}; +const updaterCapabilityKeys = [ + "settings.updates_bridge_unavailable", + "settings.updates_checking_support", + "settings.updates_packaged_only", +] as const; +const locales = { ca, en, es, fr, ja, "pt-BR": ptBR, th, vi, zh }; + +function renderUpdatesView(overrides: Partial> = {}) { + return renderToStaticMarkup( + React.createElement(UpdatesView, { + busy: false, + webDeployment: false, + appVersion: "0.12.7", + updateEnv: { supported: true }, + updateAutoCheck: true, + toggleUpdateAutoCheck: noop, + updateAutoDownload: false, + toggleUpdateAutoDownload: noop, + updateStatus: null, + anyActiveRuns: false, + checkForUpdates: noop, + downloadUpdate: noop, + installUpdateAndRestart: noop, + releaseChannel: "stable", + onReleaseChannelChange: noop, + alphaChannelSupported: true, + ...overrides, + }), + ); +} + +describe("updater capability gating", () => { + test("requires the complete Electron updater bridge before enabling controls", () => { + expect(missingElectronUpdaterMethods(null)).toEqual([ + "getChannel", + "setChannel", + "check", + "download", + "installAndRestart", + ]); + + expect( + missingElectronUpdaterMethods({ + getChannel: async () => ({ channel: "stable", feedUrl: "", currentVersion: "0.12.7" }), + setChannel: async () => ({ channel: "stable", feedUrl: "", currentVersion: "0.12.7" }), + check: async () => ({ available: false }), + }), + ).toEqual(["download", "installAndRestart"]); + }); + + test("treats development Electron builds as unsupported for update checks", () => { + expect(electronUpdaterRequiresPackagedBuild({ updateChecksSupported: false })).toBe(true); + expect(electronUpdaterRequiresPackagedBuild({ reason: "unavailable" })).toBe(true); + expect(electronUpdaterRequiresPackagedBuild({ updateChecksSupported: true })).toBe(false); + }); + + test("does not render update actions before updater support is known", () => { + const html = renderUpdatesView({ updateEnv: null }); + + expect(html).toContain("Checking update support"); + expect(html).not.toContain("Release channel"); + expect(html).not.toContain(">Check<"); + }); + + test("does not render update actions when updater bridge is unavailable", () => { + const html = renderUpdatesView({ + updateEnv: { + supported: false, + reason: "Updater bridge is unavailable. Restart OpenWork and try again.", + }, + }); + + expect(html).toContain("Updater bridge is unavailable"); + expect(html).not.toContain(">Check<"); + }); + + test("defines updater capability copy for every locale", () => { + for (const [locale, messages] of Object.entries(locales)) { + for (const key of updaterCapabilityKeys) { + const value = messages[key as keyof typeof messages]; + expect(typeof value, `${locale}:${key}`).toBe("string"); + expect(String(value).length, `${locale}:${key}`).toBeGreaterThan(0); + } + } + }); +}); diff --git a/apps/desktop/electron/updater.mjs b/apps/desktop/electron/updater.mjs index f5c96a6ee..8fc96542f 100644 --- a/apps/desktop/electron/updater.mjs +++ b/apps/desktop/electron/updater.mjs @@ -70,10 +70,12 @@ function electronUpdaterFeedUrl(channel) { function updaterChannelState(app, channel) { const normalized = normalizeElectronUpdaterChannel(channel); + const updateChecksSupported = app.isPackaged; return { channel: normalized, feedUrl: electronUpdaterFeedUrl(normalized), currentVersion: resolveAppVersion(app), + updateChecksSupported, }; }