From 52d79cb3682011da26eda20f7e36eff0d685af5f Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 28 Aug 2026 14:47:09 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + packages/electron/README.md | 2 + packages/electron/main.mjs | 82 ++++++++++++++++--- packages/ui/src/lib/desktop.ts | 6 +- packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/tr.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + .../ui/src/lib/updateInstallError.test.ts | 35 ++++++++ packages/ui/src/lib/updateInstallError.ts | 50 +++++++++++ packages/ui/src/stores/useUpdateStore.ts | 13 ++- 19 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/lib/updateInstallError.test.ts create mode 100644 packages/ui/src/lib/updateInstallError.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8363b726..869cbd6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - 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). +- 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 diff --git a/packages/electron/README.md b/packages/electron/README.md index 7a306894..c5c7df08 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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). +`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 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. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 73b6e20a..2a0de81b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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) => { try { 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); autoUpdater.on('update-downloaded', onDownloaded); 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({ event: 'Finished', data: {}, @@ -4525,20 +4589,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } catch { } } + return await installDownloadedUpdate(); } // Defer so the IPC reply flushes before the app starts shutting down. - // Without this, quitAndInstall() can race with the renderer's pending - // invoke and the restart appears to do nothing from the UI side. + // Without this, relaunch can race with the renderer's pending invoke and + // the restart appears to do nothing from the UI side. setImmediate(() => { try { - if (applyUpdate) { - killSidecar(); - autoUpdater.quitAndInstall(); - } else { - prepareForQuit(); - app.relaunch(); - app.exit(0); - } + prepareForQuit(); + app.relaunch(); + app.exit(0); } catch (err) { log.error('[electron] desktop_restart failed', err); } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 1163515d..2b18806f 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -810,7 +810,11 @@ export const restartToApplyUpdate = async (): Promise => { 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 => { diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 26725d0f..fa4074d6 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 8ccc99c0..6279f4fd 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 35cdef68..38eff40c 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2979,6 +2979,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 6a8b1fed..d77d52ce 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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 n’a 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 s’est 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': 'L’installation de la mise à jour nécessite l’application 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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1422b23b..a84c550b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3009,6 +3009,10 @@ export const dict: Record = { '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': 'ダウンロード', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 1a075c41..1b9efd89 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3013,6 +3013,10 @@ export const dict: Record = { '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': '다운로드', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index a4c534ce..bdf8e565 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2999,6 +2999,10 @@ export const dict: Record = { '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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 0cc9ed72..a3da93ae 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2979,6 +2979,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index a2579c55..ad200dd2 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -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. OpenChamber’dan çı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', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 866f91b2..2e5e3272 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2979,6 +2979,10 @@ export const dict: Record = { "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": "Завантажити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0200f596..1019c0fe 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2979,6 +2979,10 @@ export const dict: Record = { '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': '下载', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 8ade83fa..887bfedc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2976,6 +2976,10 @@ export const dict: Record = { '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': '下載', diff --git a/packages/ui/src/lib/updateInstallError.test.ts b/packages/ui/src/lib/updateInstallError.test.ts new file mode 100644 index 00000000..8715ac8a --- /dev/null +++ b/packages/ui/src/lib/updateInstallError.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/lib/updateInstallError.ts b/packages/ui/src/lib/updateInstallError.ts new file mode 100644 index 00000000..c2f46d3f --- /dev/null +++ b/packages/ui/src/lib/updateInstallError.ts @@ -0,0 +1,50 @@ +import { formatMessage, useI18nStore } from '@/lib/i18n/store'; + +const t = (key: Parameters[1], params?: Parameters[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'); +}; diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index cc7d5bf4..d413a6b6 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -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()((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))) }); } },