fix(desktop): surface failed update installs

"Restart to Update" answered the renderer with null before the install was
attempted, so a rejected install only reached main.log and the button looked
dead. The apply-update path now keeps the IPC call open until the app quits or
autoUpdater reports the failure, rolls the quit/install flags back when the app
stays up, and the update dialog shows the real reason with a translated hint for
a rejected code signature.

Also settle the download promise on downloadUpdate() itself: an already cached
payload emits no 'update-downloaded', which left that promise pending with its
listeners attached on every retry.
This commit is contained in:
Iuliia Ivashko
2026-08-28 14:47:09 +03:00
parent 1760347dc7
commit 52d79cb368
19 changed files with 221 additions and 16 deletions
+5 -1
View File
@@ -810,7 +810,11 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
return false;
}
return restartDesktopApp();
// Unlike a plain restart, an install failure (rejected signature, disabled
// updater session) must reach the update dialog instead of being reduced to
// a boolean the caller cannot explain.
await invokeDesktop('desktop_restart');
return true;
};
export const restartDesktopApp = async (): Promise<boolean> => {
+4
View File
@@ -2790,6 +2790,10 @@ export const dict = {
'updateDialog.status.updating': 'Aktualisierung läuft...',
'updateDialog.error.updateFailed': 'Aktualisierung fehlgeschlagen',
'updateDialog.error.takingLonger': 'Die Aktualisierung dauert länger als erwartet. Warten Sie einen Moment und aktualisieren Sie die Seite oder führen Sie folgenden Befehl aus: openchamber update',
'updateDialog.error.signatureRejected': 'Das heruntergeladene Update wurde abgelehnt: Seine Codesignatur passt nicht zu dieser Installation. Meist bedeutet das, dass die laufende Kopie nicht aus einer offiziellen signierten Version stammt. Installieren Sie OpenChamber aus einer offiziellen Version und aktualisieren Sie erneut.',
'updateDialog.error.updaterDisabled': 'Der Updater wurde nach einer fehlgeschlagenen Installation gestoppt. Beenden Sie OpenChamber, öffnen Sie es erneut und versuchen Sie das Update noch einmal.',
'updateDialog.error.restartFailed': 'Neustart zum Installieren des Updates fehlgeschlagen.',
'updateDialog.error.restartUnavailable': 'Das Installieren des Updates erfordert die OpenChamber-Desktop-App.',
'mobileUpdate.toast.available.title': 'OpenChamber-Update verfügbar',
'mobileUpdate.toast.available.description': 'Version {version} ist für Android bereit.',
'mobileUpdate.toast.actions.download': 'Herunterladen',
+4
View File
@@ -3013,6 +3013,10 @@ export const dict = {
'updateDialog.status.updating': 'Updating...',
'updateDialog.error.updateFailed': 'Update failed',
'updateDialog.error.takingLonger': 'Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update',
'updateDialog.error.signatureRejected': 'The downloaded update was rejected: its code signature does not match this installation. This usually means the running copy was not installed from an official signed release. Install OpenChamber from an official release, then update again.',
'updateDialog.error.updaterDisabled': 'The updater stopped after a failed install. Quit OpenChamber, open it again, and retry the update.',
'updateDialog.error.restartFailed': 'Could not restart to install the update.',
'updateDialog.error.restartUnavailable': 'Installing the update requires the OpenChamber desktop app.',
'mobileUpdate.toast.available.title': 'OpenChamber update available',
'mobileUpdate.toast.available.description': 'Version {version} is ready for Android.',
'mobileUpdate.toast.actions.download': 'Download',
+4
View File
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Actualizando...",
"updateDialog.error.updateFailed": "No se pudo actualizar",
"updateDialog.error.takingLonger": "La actualización está tardando más de lo esperado. Espera un poco y refresca, o ejecuta: openchamber update",
"updateDialog.error.signatureRejected": "La actualización descargada fue rechazada: su firma de código no coincide con esta instalación. Normalmente significa que la copia en ejecución no se instaló desde una versión oficial firmada. Instala OpenChamber desde una versión oficial y vuelve a actualizar.",
"updateDialog.error.updaterDisabled": "El actualizador se detuvo tras una instalación fallida. Cierra OpenChamber, ábrelo de nuevo y reintenta la actualización.",
"updateDialog.error.restartFailed": "No se pudo reiniciar para instalar la actualización.",
"updateDialog.error.restartUnavailable": "Instalar la actualización requiere la aplicación de escritorio de OpenChamber.",
"mobileUpdate.toast.available.title": "Actualización de OpenChamber disponible",
"mobileUpdate.toast.available.description": "La versión {version} está lista para Android.",
"mobileUpdate.toast.actions.download": "Descargar",
+4
View File
@@ -2704,6 +2704,10 @@ export const dict = {
'updateDialog.status.updating': 'Mise à jour...',
'updateDialog.error.updateFailed': 'La mise à jour a échoué',
'updateDialog.error.takingLonger': 'La mise à jour prend plus de temps que prévu. Attendez un peu et actualisez, ou exécutez : openchamber update',
'updateDialog.error.signatureRejected': 'La mise à jour téléchargée a été rejetée : sa signature de code ne correspond pas à cette installation. Cela signifie généralement que la copie en cours na pas été installée depuis une version officielle signée. Installez OpenChamber depuis une version officielle, puis relancez la mise à jour.',
'updateDialog.error.updaterDisabled': 'Le programme de mise à jour sest arrêté après une installation échouée. Quittez OpenChamber, rouvrez-le, puis réessayez la mise à jour.',
'updateDialog.error.restartFailed': 'Impossible de redémarrer pour installer la mise à jour.',
'updateDialog.error.restartUnavailable': 'Linstallation de la mise à jour nécessite lapplication de bureau OpenChamber.',
'mobileUpdate.toast.available.title': 'Mise à jour OpenChamber disponible',
'mobileUpdate.toast.available.description': 'La version {version} est prête pour Android.',
'mobileUpdate.toast.actions.download': 'Télécharger',
+4
View File
@@ -3009,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新に失敗しました',
'updateDialog.error.takingLonger': '更新に予想以上に時間がかかっています。しばらく待ってから更新するか、次を実行: openchamber update',
'updateDialog.error.signatureRejected': 'ダウンロードした更新は拒否されました。コード署名がこのインストールと一致しません。通常は、実行中のコピーが公式の署名済みリリースからインストールされていないことを意味します。公式リリースから OpenChamber をインストールし直してから、もう一度更新してください。',
'updateDialog.error.updaterDisabled': 'インストールに失敗したため、アップデーターが停止しました。OpenChamber を終了して開き直し、更新をやり直してください。',
'updateDialog.error.restartFailed': '更新をインストールするための再起動に失敗しました。',
'updateDialog.error.restartUnavailable': '更新のインストールには OpenChamber デスクトップアプリが必要です。',
'mobileUpdate.toast.available.title': 'OpenChamberの更新があります',
'mobileUpdate.toast.available.description': 'バージョン{version}をAndroidで利用できます。',
'mobileUpdate.toast.actions.download': 'ダウンロード',
+4
View File
@@ -3013,6 +3013,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '업데이트 중…',
'updateDialog.error.updateFailed': '업데이트 실패',
'updateDialog.error.takingLonger': '업데이트가 예상보다 오래 걸립니다. 잠시 기다린 뒤 새로고침하거나 `openchamber update`를 실행하세요.',
'updateDialog.error.signatureRejected': '다운로드한 업데이트가 거부되었습니다. 코드 서명이 이 설치본과 일치하지 않습니다. 보통 실행 중인 복사본이 공식 서명 릴리스에서 설치되지 않았다는 뜻입니다. 공식 릴리스에서 OpenChamber를 설치한 뒤 다시 업데이트하세요.',
'updateDialog.error.updaterDisabled': '설치에 실패하여 업데이터가 중지되었습니다. OpenChamber를 종료했다가 다시 열고 업데이트를 재시도하세요.',
'updateDialog.error.restartFailed': '업데이트를 설치하기 위한 재시작에 실패했습니다.',
'updateDialog.error.restartUnavailable': '업데이트 설치에는 OpenChamber 데스크톱 앱이 필요합니다.',
'mobileUpdate.toast.available.title': 'OpenChamber 업데이트 사용 가능',
'mobileUpdate.toast.available.description': 'Android용 버전 {version}이 준비되었습니다.',
'mobileUpdate.toast.actions.download': '다운로드',
+4
View File
@@ -2999,6 +2999,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.actions.restartToUpdate': 'Uruchom ponownie, aby zaktualizować',
'updateDialog.actions.updateNow': 'Aktualizuj teraz',
'updateDialog.error.takingLonger': 'Aktualizacja trwa dłużej niż oczekiwano. Poczekaj chwilę i odśwież albo uruchom: openchamber update',
'updateDialog.error.signatureRejected': 'Pobrana aktualizacja została odrzucona: jej podpis kodu nie pasuje do tej instalacji. Zwykle oznacza to, że uruchomiona kopia nie pochodzi z oficjalnego podpisanego wydania. Zainstaluj OpenChamber z oficjalnego wydania i zaktualizuj ponownie.',
'updateDialog.error.updaterDisabled': 'Aktualizator zatrzymał się po nieudanej instalacji. Zamknij OpenChamber, otwórz go ponownie i spróbuj zaktualizować jeszcze raz.',
'updateDialog.error.restartFailed': 'Nie udało się uruchomić ponownie, aby zainstalować aktualizację.',
'updateDialog.error.restartUnavailable': 'Instalacja aktualizacji wymaga aplikacji desktopowej OpenChamber.',
'updateDialog.error.updateFailed': 'Aktualizacja nie powiodła się',
'mobileUpdate.toast.available.title': 'Dostępna aktualizacja OpenChamber',
'mobileUpdate.toast.available.description': 'Wersja {version} jest gotowa dla Androida.',
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Atualizando...",
"updateDialog.error.updateFailed": "Não foi possível atualizar",
"updateDialog.error.takingLonger": "A atualização está demorando mais do que o esperado. Aguarde um pouco e atualize, ou execute: openchamber update",
"updateDialog.error.signatureRejected": "A atualização baixada foi rejeitada: a assinatura de código não corresponde a esta instalação. Isso costuma significar que a cópia em execução não foi instalada a partir de uma versão oficial assinada. Instale o OpenChamber a partir de uma versão oficial e atualize novamente.",
"updateDialog.error.updaterDisabled": "O atualizador parou após uma instalação com falha. Feche o OpenChamber, abra-o de novo e tente atualizar outra vez.",
"updateDialog.error.restartFailed": "Não foi possível reiniciar para instalar a atualização.",
"updateDialog.error.restartUnavailable": "Instalar a atualização exige o aplicativo de desktop do OpenChamber.",
"mobileUpdate.toast.available.title": "Atualização do OpenChamber disponível",
"mobileUpdate.toast.available.description": "A versão {version} está pronta para Android.",
"mobileUpdate.toast.actions.download": "Baixar",
+4
View File
@@ -2940,6 +2940,10 @@ export const dict = {
'updateDialog.status.updating': 'Güncelleniyor...',
'updateDialog.error.updateFailed': 'Güncelleme başarısız oldu',
'updateDialog.error.takingLonger': 'Güncelleme beklenenden uzun sürüyor. Biraz bekleyip sayfayı yenileyin ya da şunu çalıştırın: openchamber update',
'updateDialog.error.signatureRejected': 'İndirilen güncelleme reddedildi: kod imzası bu kurulumla eşleşmiyor. Bu genellikle çalışan kopyanın resmi imzalı bir sürümden kurulmadığı anlamına gelir. OpenChamber’ı resmi bir sürümden kurun ve yeniden güncelleyin.',
'updateDialog.error.updaterDisabled': 'Başarısız bir kurulumdan sonra güncelleyici durdu. OpenChamberdan çıkın, yeniden açın ve güncellemeyi tekrar deneyin.',
'updateDialog.error.restartFailed': 'Güncellemeyi kurmak için yeniden başlatılamadı.',
'updateDialog.error.restartUnavailable': 'Güncellemeyi kurmak için OpenChamber masaüstü uygulaması gerekir.',
'mobileUpdate.toast.available.title': 'OpenChamber güncellemesi var',
'mobileUpdate.toast.available.description': '{version} sürümü Android için hazır.',
'mobileUpdate.toast.actions.download': 'İndir',
+4
View File
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Оновлення...",
"updateDialog.error.updateFailed": "Помилка оновлення",
"updateDialog.error.takingLonger": "Оновлення триває довше, ніж очікувалося. Зачекайте трохи та оновіть або запустіть: openchamber update",
"updateDialog.error.signatureRejected": "Завантажене оновлення відхилено: його підпис коду не збігається з цією інсталяцією. Зазвичай це означає, що запущену копію встановлено не з офіційного підписаного релізу. Встановіть OpenChamber з офіційного релізу й оновіться ще раз.",
"updateDialog.error.updaterDisabled": "Оновлювач зупинився після невдалого встановлення. Закрийте OpenChamber, відкрийте його знову й повторіть оновлення.",
"updateDialog.error.restartFailed": "Не вдалося перезапустити, щоб встановити оновлення.",
"updateDialog.error.restartUnavailable": "Щоб встановити оновлення, потрібен застосунок OpenChamber для комп’ютера.",
"mobileUpdate.toast.available.title": "Доступне оновлення OpenChamber",
"mobileUpdate.toast.available.description": "Версія {version} готова для Android.",
"mobileUpdate.toast.actions.download": "Завантажити",
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失败',
'updateDialog.error.takingLonger': '更新耗时超出预期。请稍等后刷新,或运行:openchamber update',
'updateDialog.error.signatureRejected': '下载的更新被拒绝:其代码签名与当前安装不匹配。这通常说明正在运行的副本不是从官方签名版本安装的。请从官方版本安装 OpenChamber,然后再次更新。',
'updateDialog.error.updaterDisabled': '一次安装失败后,更新程序已停止。请退出 OpenChamber,重新打开后再试一次更新。',
'updateDialog.error.restartFailed': '无法重启以安装更新。',
'updateDialog.error.restartUnavailable': '安装更新需要 OpenChamber 桌面应用。',
'mobileUpdate.toast.available.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用于 Android。',
'mobileUpdate.toast.actions.download': '下载',
@@ -2976,6 +2976,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失敗',
'updateDialog.error.takingLonger': '更新耗時超出預期。請稍等後重新整理,或執行:openchamber update',
'updateDialog.error.signatureRejected': '下載的更新遭到拒絕:其程式碼簽章與目前的安裝不符。這通常表示執行中的副本不是從官方簽章版本安裝的。請從官方版本安裝 OpenChamber,再重新更新。',
'updateDialog.error.updaterDisabled': '一次安裝失敗後,更新程式已停止。請結束 OpenChamber,重新開啟後再試一次更新。',
'updateDialog.error.restartFailed': '無法重新啟動以安裝更新。',
'updateDialog.error.restartUnavailable': '安裝更新需要 OpenChamber 桌面應用程式。',
'mobileUpdate.toast.available.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用於 Android。',
'mobileUpdate.toast.actions.download': '下載',
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import { classifyUpdateInstallError, getUpdateInstallErrorMessage } from './updateInstallError';
describe('classifyUpdateInstallError', () => {
test('recognizes a rejected code signature', () => {
const error = new Error(
'Code signature at URL file:///Users/me/Library/Caches/dev.openchamber.desktop.ShipIt/update.afN56TW/OpenChamber.app/ did not pass validation: code failed to satisfy specified code requirement(s)',
);
expect(classifyUpdateInstallError(error)).toBe('signature');
});
test('recognizes the disabled updater session left by an earlier failure', () => {
expect(classifyUpdateInstallError(new Error('The command is disabled and cannot be executed'))).toBe(
'updater-disabled',
);
});
test('leaves an unknown installer failure unclassified', () => {
expect(classifyUpdateInstallError(new Error('ENOSPC: no space left on device'))).toBe('unknown');
});
});
describe('getUpdateInstallErrorMessage', () => {
test('keeps the raw updater text for an unknown failure', () => {
expect(getUpdateInstallErrorMessage(new Error('ENOSPC: no space left on device'))).toBe(
'ENOSPC: no space left on device',
);
});
test('never returns an empty message', () => {
expect(getUpdateInstallErrorMessage(new Error(' ')).length).toBeGreaterThan(0);
expect(getUpdateInstallErrorMessage(new Error('')).length).toBeGreaterThan(0);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
formatMessage(useI18nStore.getState().dictionary, key, params);
type UpdateInstallFailureReason = 'signature' | 'updater-disabled' | 'unknown';
/**
* Classify a desktop updater install failure. The platform installers report
* these as opaque English strings, and the two known ones need very different
* advice from "something went wrong".
*/
export const classifyUpdateInstallError = (error: Error): UpdateInstallFailureReason => {
const normalized = error.message.toLowerCase();
if (
normalized.includes('code signature')
|| normalized.includes('did not pass validation')
|| normalized.includes('code requirement')
|| normalized.includes('not signed')
) {
return 'signature';
}
// Squirrel.Mac refuses every later attempt in the same app session once an
// install failed, so this is a follow-up of an earlier failure.
if (normalized.includes('command is disabled')) {
return 'updater-disabled';
}
return 'unknown';
};
/**
* Message for a failed "Restart to Update". Falls back to the raw updater text
* so an unrecognized failure is still visible rather than silently swallowed.
*/
export const getUpdateInstallErrorMessage = (error: Error): string => {
const reason = classifyUpdateInstallError(error);
if (reason === 'signature') {
return t('updateDialog.error.signatureRejected');
}
if (reason === 'updater-disabled') {
return t('updateDialog.error.updaterDisabled');
}
return error.message.trim() || t('updateDialog.error.restartFailed');
};
+9 -4
View File
@@ -11,6 +11,8 @@ import {
isVSCodeRuntime,
isWebRuntime,
} from '@/lib/desktop';
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { getUpdateInstallErrorMessage } from '@/lib/updateInstallError';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
@@ -314,15 +316,18 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
return;
}
set({ error: null });
try {
const ok = await restartToApplyUpdate();
if (!ok) {
throw new Error('Desktop restart only works on Local instance');
// No desktop bridge at all — the update was never installable here.
throw new Error(formatMessage(useI18nStore.getState().dictionary, 'updateDialog.error.restartUnavailable'));
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to restart',
});
// Keep the real installer failure; the dialog shows it and the button
// stays clickable for another attempt.
set({ error: getUpdateInstallErrorMessage(error instanceof Error ? error : new Error(String(error))) });
}
},