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
+1
View File
@@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). - Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran).
- Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss).
- Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour).
- Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry.
## [1.21.0] - 2026-08-26 ## [1.21.0] - 2026-08-26
+2
View File
@@ -98,6 +98,8 @@ Desktop clears AppImage `ARGV0` from `process.env` before probing the login shel
Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet).
`desktop_restart` does not answer the renderer before the install is decided. On the apply-update path it calls `quitAndInstall()` and keeps the IPC call open until the app quits or `autoUpdater` emits `error`, which the platform installers do asynchronously (a rejected code signature, or a Squirrel session disabled by an earlier failure). A failed install rejects the IPC call so the update dialog can show it, and the quit/install flags are rolled back because the app is staying up. A still-running app after the grace period resolves the call.
### Updater End-to-End Fixture ### Updater End-to-End Fixture
A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture. A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture.
+71 -11
View File
@@ -3149,6 +3149,59 @@ const setupAutoUpdater = () => {
}); });
}; };
// quitAndInstall() reports failures (rejected code signature, a Squirrel
// session already disabled by an earlier failure) asynchronously on the
// 'error' event, long after the call returns. Give the install that long to
// either take the app down or report why it did not.
const UPDATE_INSTALL_GRACE_MS = 15_000;
/**
* Hand the downloaded update to the platform installer and keep the IPC call
* open until the app quits or the updater reports a failure, so a rejected
* install reaches the renderer instead of dying in the log. Restores the
* quit/install flags when the install never happens.
*/
const installDownloadedUpdate = () => new Promise((resolve, reject) => {
let settled = false;
const rollbackQuitState = () => {
state.quitRequested = false;
state.installingUpdate = false;
};
const fail = (error) => {
if (settled) return;
settled = true;
clearTimeout(graceTimer);
autoUpdater.off('error', fail);
rollbackQuitState();
log.error('[electron] update install failed', error);
reject(error instanceof Error ? error : new Error(String(error)));
};
// Still running after the grace period: the install is underway and the app
// is shutting down, so release the pending IPC reply.
const graceTimer = setTimeout(() => {
if (settled) return;
settled = true;
autoUpdater.off('error', fail);
resolve(null);
}, UPDATE_INSTALL_GRACE_MS);
autoUpdater.on('error', fail);
// Defer so the renderer's invoke channel is idle before the app starts
// shutting down.
setImmediate(() => {
try {
killSidecar();
autoUpdater.quitAndInstall();
} catch (error) {
fail(error);
}
});
});
const parseRelevantChangelogNotes = async (fromVersion, toVersion) => { const parseRelevantChangelogNotes = async (fromVersion, toVersion) => {
try { try {
const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10_000) }); const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10_000) });
@@ -4486,9 +4539,20 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
const onError = (error) => finish(reject, error); const onError = (error) => finish(reject, error);
autoUpdater.on('update-downloaded', onDownloaded); autoUpdater.on('update-downloaded', onDownloaded);
autoUpdater.on('error', onError); autoUpdater.on('error', onError);
Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error)); // downloadUpdate() resolves once the payload is on disk. It stays
// the authoritative signal: when the file was already cached the
// updater emits no 'update-downloaded', and waiting only for the
// event left this promise pending and its listeners attached on
// every retry.
Promise.resolve(autoUpdater.downloadUpdate())
.then(() => finish(resolve, null))
.catch((error) => finish(reject, error));
}); });
} }
// The 'update-downloaded' event does not fire for an already cached
// payload, so record the payload as ready here too; otherwise restart
// would relaunch without installing anything.
state.pendingUpdate.downloaded = true;
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Finished', event: 'Finished',
data: {}, data: {},
@@ -4525,20 +4589,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} catch { } catch {
} }
} }
return await installDownloadedUpdate();
} }
// Defer so the IPC reply flushes before the app starts shutting down. // Defer so the IPC reply flushes before the app starts shutting down.
// Without this, quitAndInstall() can race with the renderer's pending // Without this, relaunch can race with the renderer's pending invoke and
// invoke and the restart appears to do nothing from the UI side. // the restart appears to do nothing from the UI side.
setImmediate(() => { setImmediate(() => {
try { try {
if (applyUpdate) { prepareForQuit();
killSidecar(); app.relaunch();
autoUpdater.quitAndInstall(); app.exit(0);
} else {
prepareForQuit();
app.relaunch();
app.exit(0);
}
} catch (err) { } catch (err) {
log.error('[electron] desktop_restart failed', err); log.error('[electron] desktop_restart failed', err);
} }
+5 -1
View File
@@ -810,7 +810,11 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
return false; 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> => { export const restartDesktopApp = async (): Promise<boolean> => {
+4
View File
@@ -2790,6 +2790,10 @@ export const dict = {
'updateDialog.status.updating': 'Aktualisierung läuft...', 'updateDialog.status.updating': 'Aktualisierung läuft...',
'updateDialog.error.updateFailed': 'Aktualisierung fehlgeschlagen', '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.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.title': 'OpenChamber-Update verfügbar',
'mobileUpdate.toast.available.description': 'Version {version} ist für Android bereit.', 'mobileUpdate.toast.available.description': 'Version {version} ist für Android bereit.',
'mobileUpdate.toast.actions.download': 'Herunterladen', 'mobileUpdate.toast.actions.download': 'Herunterladen',
+4
View File
@@ -3013,6 +3013,10 @@ export const dict = {
'updateDialog.status.updating': 'Updating...', 'updateDialog.status.updating': 'Updating...',
'updateDialog.error.updateFailed': 'Update failed', '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.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.title': 'OpenChamber update available',
'mobileUpdate.toast.available.description': 'Version {version} is ready for Android.', 'mobileUpdate.toast.available.description': 'Version {version} is ready for Android.',
'mobileUpdate.toast.actions.download': 'Download', 'mobileUpdate.toast.actions.download': 'Download',
+4
View File
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Actualizando...", "updateDialog.status.updating": "Actualizando...",
"updateDialog.error.updateFailed": "No se pudo actualizar", "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.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.title": "Actualización de OpenChamber disponible",
"mobileUpdate.toast.available.description": "La versión {version} está lista para Android.", "mobileUpdate.toast.available.description": "La versión {version} está lista para Android.",
"mobileUpdate.toast.actions.download": "Descargar", "mobileUpdate.toast.actions.download": "Descargar",
+4
View File
@@ -2704,6 +2704,10 @@ export const dict = {
'updateDialog.status.updating': 'Mise à jour...', 'updateDialog.status.updating': 'Mise à jour...',
'updateDialog.error.updateFailed': 'La mise à jour a échoué', '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.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.title': 'Mise à jour OpenChamber disponible',
'mobileUpdate.toast.available.description': 'La version {version} est prête pour Android.', 'mobileUpdate.toast.available.description': 'La version {version} est prête pour Android.',
'mobileUpdate.toast.actions.download': 'Télécharger', 'mobileUpdate.toast.actions.download': 'Télécharger',
+4
View File
@@ -3009,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...', 'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新に失敗しました', 'updateDialog.error.updateFailed': '更新に失敗しました',
'updateDialog.error.takingLonger': '更新に予想以上に時間がかかっています。しばらく待ってから更新するか、次を実行: openchamber update', '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.title': 'OpenChamberの更新があります',
'mobileUpdate.toast.available.description': 'バージョン{version}をAndroidで利用できます。', 'mobileUpdate.toast.available.description': 'バージョン{version}をAndroidで利用できます。',
'mobileUpdate.toast.actions.download': 'ダウンロード', 'mobileUpdate.toast.actions.download': 'ダウンロード',
+4
View File
@@ -3013,6 +3013,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '업데이트 중…', 'updateDialog.status.updating': '업데이트 중…',
'updateDialog.error.updateFailed': '업데이트 실패', 'updateDialog.error.updateFailed': '업데이트 실패',
'updateDialog.error.takingLonger': '업데이트가 예상보다 오래 걸립니다. 잠시 기다린 뒤 새로고침하거나 `openchamber update`를 실행하세요.', '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.title': 'OpenChamber 업데이트 사용 가능',
'mobileUpdate.toast.available.description': 'Android용 버전 {version}이 준비되었습니다.', 'mobileUpdate.toast.available.description': 'Android용 버전 {version}이 준비되었습니다.',
'mobileUpdate.toast.actions.download': '다운로드', '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.restartToUpdate': 'Uruchom ponownie, aby zaktualizować',
'updateDialog.actions.updateNow': 'Aktualizuj teraz', '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.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ę', 'updateDialog.error.updateFailed': 'Aktualizacja nie powiodła się',
'mobileUpdate.toast.available.title': 'Dostępna aktualizacja OpenChamber', 'mobileUpdate.toast.available.title': 'Dostępna aktualizacja OpenChamber',
'mobileUpdate.toast.available.description': 'Wersja {version} jest gotowa dla Androida.', '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.status.updating": "Atualizando...",
"updateDialog.error.updateFailed": "Não foi possível atualizar", "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.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.title": "Atualização do OpenChamber disponível",
"mobileUpdate.toast.available.description": "A versão {version} está pronta para Android.", "mobileUpdate.toast.available.description": "A versão {version} está pronta para Android.",
"mobileUpdate.toast.actions.download": "Baixar", "mobileUpdate.toast.actions.download": "Baixar",
+4
View File
@@ -2940,6 +2940,10 @@ export const dict = {
'updateDialog.status.updating': 'Güncelleniyor...', 'updateDialog.status.updating': 'Güncelleniyor...',
'updateDialog.error.updateFailed': 'Güncelleme başarısız oldu', '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.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.title': 'OpenChamber güncellemesi var',
'mobileUpdate.toast.available.description': '{version} sürümü Android için hazır.', 'mobileUpdate.toast.available.description': '{version} sürümü Android için hazır.',
'mobileUpdate.toast.actions.download': 'İndir', 'mobileUpdate.toast.actions.download': 'İndir',
+4
View File
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Оновлення...", "updateDialog.status.updating": "Оновлення...",
"updateDialog.error.updateFailed": "Помилка оновлення", "updateDialog.error.updateFailed": "Помилка оновлення",
"updateDialog.error.takingLonger": "Оновлення триває довше, ніж очікувалося. Зачекайте трохи та оновіть або запустіть: openchamber update", "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.title": "Доступне оновлення OpenChamber",
"mobileUpdate.toast.available.description": "Версія {version} готова для Android.", "mobileUpdate.toast.available.description": "Версія {version} готова для Android.",
"mobileUpdate.toast.actions.download": "Завантажити", "mobileUpdate.toast.actions.download": "Завантажити",
@@ -2979,6 +2979,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...', 'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失败', 'updateDialog.error.updateFailed': '更新失败',
'updateDialog.error.takingLonger': '更新耗时超出预期。请稍等后刷新,或运行:openchamber update', '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.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用于 Android。', 'mobileUpdate.toast.available.description': '版本 {version} 已可用于 Android。',
'mobileUpdate.toast.actions.download': '下载', 'mobileUpdate.toast.actions.download': '下载',
@@ -2976,6 +2976,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...', 'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失敗', 'updateDialog.error.updateFailed': '更新失敗',
'updateDialog.error.takingLonger': '更新耗時超出預期。請稍等後重新整理,或執行:openchamber update', '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.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用於 Android。', 'mobileUpdate.toast.available.description': '版本 {version} 已可用於 Android。',
'mobileUpdate.toast.actions.download': '下載', '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, isVSCodeRuntime,
isWebRuntime, isWebRuntime,
} from '@/lib/desktop'; } from '@/lib/desktop';
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { getUpdateInstallErrorMessage } from '@/lib/updateInstallError';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform'; import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
@@ -314,15 +316,18 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
return; return;
} }
set({ error: null });
try { try {
const ok = await restartToApplyUpdate(); const ok = await restartToApplyUpdate();
if (!ok) { 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) { } catch (error) {
set({ // Keep the real installer failure; the dialog shows it and the button
error: error instanceof Error ? error.message : 'Failed to restart', // stays clickable for another attempt.
}); set({ error: getUpdateInstallErrorMessage(error instanceof Error ? error : new Error(String(error))) });
} }
}, },