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,
};
}