From ab2ef4a66add2856fe88164fa9a541af67848752 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 18 Sep 2026 22:47:25 +0800 Subject: [PATCH] fix(updater): add step and path to update failure hints --- apps/desktop/src/composables/useAppUpdater.ts | 9 +- apps/desktop/src/i18n/locales/az.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + src-tauri/src/commands/update.rs | 4 +- src-tauri/src/commands/update_cache.rs | 91 +++++++++++++++---- src-tauri/src/commands/update_portable.rs | 24 ++++- 14 files changed, 127 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts index 9054f22691..36587fc6a9 100644 --- a/apps/desktop/src/composables/useAppUpdater.ts +++ b/apps/desktop/src/composables/useAppUpdater.ts @@ -206,9 +206,15 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) { const lower = message.toLowerCase(); if (lower.includes("cancel")) return t("updates.downloadCanceled"); if (lower.includes("403") || lower.includes("rate limit")) return t("updates.rateLimited"); + // A blocked update cache is a local write problem, not a download problem. + if (lower.includes("update cache")) return t("updates.cacheWriteFailed", { error: message }); if (["stalled", "timeout", "all available mirrors", "error sending request", "failed to download"].some((part) => lower.includes(part))) return t("updates.networkOrMirrorFailed"); return t("updates.downloadFailed", { error: message }); } + function formatInstallError(message: string): string { + if (message.toLowerCase().includes("powershell")) return t("updates.portableHelperFailed", { error: message }); + return t("updates.installFailed", { error: message }); + } async function checkUpdates(checkOptions: { silent?: boolean } = {}) { if (disposed || isIgnoringUpdate.value) return; if (!checkOptions.silent) showUpdateDialog.value = true; @@ -393,7 +399,8 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) { failed = true; phase.value = installed ? "restart" : "ready"; updateCheckFailed.value = true; - updateCheckMessage.value = t(installed ? "updates.restartFailed" : "updates.installFailed", { error: error instanceof Error ? error.message : String(error) }); + const message = error instanceof Error ? error.message : String(error); + updateCheckMessage.value = installed ? t("updates.restartFailed", { error: message }) : formatInstallError(message); } finally { // Successful handoff must keep every window frozen until the process exits. if (failed) { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 29362a0fc1..b5358c7374 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -362,6 +362,7 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 üçün xüsusi WebView2 109 oflayn quraşdırıcısı tələb olunur. Yeniləmək üçün buraxılış səhifəsini açın və Windows 7 paketini endirin.", downloading: "Endirilir: {progress}%", downloadFailed: "Yeniləməni endirmək mümkün olmadı: {error}", + cacheWriteFailed: "Endirilən yeniləmə lokal keşə yazıla bilmədi: {error}. Antivirus proqramının DBX-i bloklamadığını və keş qovluğunun yazıla bilən olduğunu yoxlayıb yenidən cəhd edin.", downloadCanceled: "Yeniləmənin endirilməsi ləğv edildi.", readyToInstall: "DBX {version} quraşdırılmağa hazırdır.", installNow: "İndi quraşdır", @@ -371,6 +372,7 @@ export default withEnglishFallback({ sourceOfficial: "Rəsmi mənbə", sourceCnb: "CNB", installing: "Yeniləmə quraşdırılır...", + portableHelperFailed: "Daşınabilən yeniləmə köməkçisi işə salına bilmədi: {error}", installFailed: "Yeniləməni quraşdırmaq mümkün olmadı: {error}", restart: "Çıx və yenidən başlat", restartFailed: "Tətbiqi yenidən başlatmaq mümkün olmadı: {error}", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8f0098fc5b..2250f11d40 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -505,6 +505,7 @@ export default { windows7ManualUpdate: "Windows 7 requires the dedicated WebView2 109 offline installer. Open the release page and download the Windows 7 package to update.", downloading: "Downloading {progress}%", downloadFailed: "Update download failed: {error}", + cacheWriteFailed: "Could not store the downloaded update: {error}. Check whether antivirus software blocks DBX or the update cache folder is writable, then retry.", downloadCanceled: "Update download canceled.", readyToInstall: "DBX {version} is ready to install.", installNow: "Install Now", @@ -514,6 +515,7 @@ export default { sourceOfficial: "Official source", sourceCnb: "CNB", installing: "Installing update...", + portableHelperFailed: "The portable update helper could not start: {error}", installFailed: "Update installation failed: {error}", restart: "Exit & Restart", restartFailed: "Failed to restart app: {error}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f3dba59f13..089783a6d5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -496,12 +496,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 requiere el instalador sin conexión dedicado de WebView2 109. Abre la página de lanzamiento y descarga el paquete para Windows 7.", downloading: "Descargando {progress}%", downloadFailed: "Error al descargar la actualización: {error}", + cacheWriteFailed: "No se pudo guardar la actualización descargada en la caché local: {error}. Comprueba si el antivirus bloquea DBX o si la carpeta de caché permite escritura y vuelve a intentarlo.", downloadCanceled: "Descarga de actualización cancelada.", readyToInstall: "DBX {version} está listo para instalar.", installNow: "Instalar ahora", installedReadyToRestart: "DBX se ha actualizado. Reinicia para finalizar.", networkOrMirrorFailed: "El tiempo de espera de la conexión de red se ha agotado o los espejos de descarga no están disponibles. Compruebe su red o haga clic en 'Abrir lanzamiento' para descargar manualmente.", installing: "Instalando actualización...", + portableHelperFailed: "No se pudo iniciar el asistente de actualización portátil: {error}", installFailed: "Error al instalar la actualización: {error}", restart: "Salir y reiniciar", restartFailed: "Error al reiniciar la aplicación: {error}", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c754dbc113..88cb9aea7f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -495,12 +495,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 richiede il programma di installazione offline dedicato WebView2 109. Apri la pagina della release e scarica il pacchetto per Windows 7.", downloading: "Download in corso {progress}%", downloadFailed: "Download dell'aggiornamento non riuscito: {error}", + cacheWriteFailed: "Impossibile salvare l'aggiornamento scaricato nella cache locale: {error}. Verifica se l'antivirus blocca DBX o se la cartella della cache è scrivibile, quindi riprova.", downloadCanceled: "Download dell'aggiornamento annullato.", readyToInstall: "DBX {version} è pronto per l'installazione.", installNow: "Installa ora", installedReadyToRestart: "DBX è stato aggiornato. Riavvia per completare.", networkOrMirrorFailed: "Connessione di rete scaduta o server mirror temporaneamente non disponibili. Controlla la rete o fai clic su 'Apri Release' per scaricare manualmente.", installing: "Installazione dell'aggiornamento...", + portableHelperFailed: "Impossibile avviare l'helper di aggiornamento portatile: {error}", installFailed: "Installazione dell'aggiornamento non riuscita: {error}", restart: "Esci e Riavvia", restartFailed: "Impossibile riavviare l'app: {error}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 61216f1540..16fd12d3cc 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -496,12 +496,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 では専用の WebView2 109 オフラインインストーラーが必要です。リリースページから Windows 7 用パッケージをダウンロードして更新してください。", downloading: "ダウンロード中 {progress}%", downloadFailed: "アップデートのダウンロードに失敗しました: {error}", + cacheWriteFailed: "ダウンロードしたアップデートをローカルキャッシュに書き込めませんでした: {error}。ウイルス対策ソフトが DBX をブロックしていないか、キャッシュフォルダーに書き込めるかを確認してから再試行してください。", downloadCanceled: "アップデートのダウンロードがキャンセルされました。", readyToInstall: "DBX {version} のインストール準備ができました。", installNow: "今すぐインストール", installedReadyToRestart: "DBX が更新されました。再起動して完了してください。", networkOrMirrorFailed: "ネットワーク接続がタイムアウトしたか、ミラーサーバーにアクセスできません。ネットワークを確認するか、「リリースページを開く」をクリックして手動でダウンロードしてください。", installing: "アップデートをインストール中...", + portableHelperFailed: "ポータブル版アップデートのヘルパーを起動できませんでした: {error}", installFailed: "アップデートのインストールに失敗しました: {error}", restart: "終了して再起動", restartFailed: "アプリの再起動に失敗しました: {error}", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 0ac862749d..dbb7dfe9ca 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -503,12 +503,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7은 전용 WebView2 109 오프라인 설치 프로그램이 필요합니다. 릴리스 페이지를 열어 Windows 7 패키지를 다운로드하여 업데이트하세요.", downloading: "다운로드 중 {progress}%", downloadFailed: "업데이트 다운로드에 실패했습니다: {error}", + cacheWriteFailed: "다운로드한 업데이트를 로컬 캐시에 저장하지 못했습니다: {error}. 백신 프로그램이 DBX를 차단하는지 또는 캐시 폴더에 쓸 수 있는지 확인한 후 다시 시도하세요.", downloadCanceled: "업데이트 다운로드가 취소되었습니다.", readyToInstall: "DBX {version} 설치 준비가 완료되었습니다.", installNow: "지금 설치", installedReadyToRestart: "DBX가 업데이트되었습니다. 다시 시작하여 완료하세요.", networkOrMirrorFailed: "네트워크 연결 시간이 초과되었거나 다운로드 미러에 연결할 수 없습니다. 네트워크를 확인하거나 '릴리스 열기'를 클릭하여 수동으로 다운로드하세요.", installing: "업데이트 설치 중...", + portableHelperFailed: "포터블 업데이트 도우미를 시작하지 못했습니다: {error}", installFailed: "업데이트 설치에 실패했습니다: {error}", restart: "종료 후 다시 시작", restartFailed: "앱을 다시 시작하는 데 실패했습니다: {error}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 901f12dedc..1516f8b705 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -496,12 +496,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "O Windows 7 requer o instalador offline dedicado do WebView2 109. Abra a página da versão e baixe o pacote para Windows 7.", downloading: "Baixando {progress}%", downloadFailed: "Falha ao baixar a atualização: {error}", + cacheWriteFailed: "Não foi possível gravar a atualização baixada no cache local: {error}. Verifique se o antivírus bloqueia o DBX ou se a pasta de cache permite gravação e tente novamente.", downloadCanceled: "Download da atualização cancelado.", readyToInstall: "DBX {version} está pronto para instalar.", installNow: "Instalar agora", installedReadyToRestart: "O DBX foi atualizado. Reinicie para concluir.", networkOrMirrorFailed: "O tempo de conexão esgotou ou os espelhos de download estão indisponíveis. Verifique sua rede ou clique em 'Abrir Lançamento' para baixar manualmente.", installing: "Instalando atualização...", + portableHelperFailed: "Não foi possível iniciar o assistente de atualização portátil: {error}", installFailed: "Falha ao instalar a atualização: {error}", restart: "Sair e Reiniciar", restartFailed: "Falha ao reiniciar o aplicativo: {error}", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index ed7561422c..88ee39932c 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -364,6 +364,7 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 için özel WebView2 109 çevrimdışı yükleyicisi gerekir. Güncellemek için sürüm sayfasını açıp Windows 7 paketini indirin.", downloading: "İndiriliyor %{progress}", downloadFailed: "Güncelleme indirilemedi: {error}", + cacheWriteFailed: "İndirilen güncelleme yerel önbelleğe yazılamadı: {error}. Antivirüs yazılımının DBX'i engelleyip engellemediğini veya önbellek klasörünün yazılabilir olduğunu kontrol edip yeniden deneyin.", downloadCanceled: "Güncelleme indirmesi iptal edildi.", readyToInstall: "DBX {version} kuruluma hazır.", installNow: "Şimdi Kur", @@ -373,6 +374,7 @@ export default withEnglishFallback({ sourceOfficial: "Resmî kaynak", sourceCnb: "CNB", installing: "Güncelleme kuruluyor...", + portableHelperFailed: "Taşınabilir güncelleme yardımcısı başlatılamadı: {error}", installFailed: "Güncelleme kurulumu başarısız: {error}", restart: "Çık ve Yeniden Başlat", restartFailed: "Uygulama yeniden başlatılamadı: {error}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 49a0dc0772..f6881ec132 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -421,6 +421,7 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 需要使用专用的 WebView2 109 离线安装包更新。请打开下载页并下载 Windows 7 安装包。", downloading: "下载中 {progress}%", downloadFailed: "更新下载失败:{error}", + cacheWriteFailed: "更新包写入本地缓存失败:{error}。请检查杀毒软件是否拦截 DBX、或缓存目录是否可写,然后重试。", downloadCanceled: "已取消更新下载。", readyToInstall: "DBX {version} 已下载完成,可以安装了。", installNow: "立即安装", @@ -430,6 +431,7 @@ export default withEnglishFallback({ sourceOfficial: "官方源", sourceCnb: "CNB", installing: "正在安装更新...", + portableHelperFailed: "便携版更新助手启动失败:{error}。便携版更新依赖 Windows PowerShell,可先安装或修复 PowerShell,也可打开下载页手动更新。", installFailed: "更新安装失败:{error}", restart: "退出并重启", restartFailed: "重启应用失败:{error}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 37d1899c94..584f8c8d03 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -499,12 +499,14 @@ export default withEnglishFallback({ windows7ManualUpdate: "Windows 7 需要使用專用的 WebView2 109 離線安裝套件更新。請開啟下載頁並下載 Windows 7 安裝套件。", downloading: "下載中 {progress}%", downloadFailed: "更新下載失敗:{error}", + cacheWriteFailed: "更新套件寫入本機快取失敗:{error}。請檢查防毒軟體是否攔截 DBX,或快取目錄是否可寫入,然後重試。", downloadCanceled: "已取消更新下載。", readyToInstall: "DBX {version} 已下載完成,可以安裝了。", installNow: "立即安裝", installedReadyToRestart: "DBX 已更新完成,重新啟動後生效。", networkOrMirrorFailed: "網路連接超時或所有鏡像源暫不可用。請檢查網路狀態,或點擊“開啟下載頁”手動下載。", installing: "正在安裝更新...", + portableHelperFailed: "可攜版更新助手啟動失敗:{error}。可攜版更新依賴 Windows PowerShell,可先安裝或修復 PowerShell,也可開啟下載頁手動更新。", installFailed: "更新安裝失敗:{error}", restart: "結束並重新啟動", restartFailed: "重新啟動應用程式失敗:{error}", diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index e5b3704e9f..46940d5284 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -499,7 +499,9 @@ pub async fn download_update( } let cancellation = state.begin_download()?; let result = async { - update_cache::discard(&update_cache::root(&app)?)?; + let cache_root = update_cache::root(&app)?; + update_cache::discard(&cache_root)?; + update_cache::ensure_writable(&cache_root)?; let (ready, manifest, signature, notes) = if portable_mode { let (archive, signature) = download_portable_update_inner(&app, &source, &version, &attempt_id, &cancellation).await?; diff --git a/src-tauri/src/commands/update_cache.rs b/src-tauri/src/commands/update_cache.rs index 3f6c2e6c42..f2512c16f9 100644 --- a/src-tauri/src/commands/update_cache.rs +++ b/src-tauri/src/commands/update_cache.rs @@ -30,9 +30,21 @@ pub(super) struct CacheRecord { pub signature: Option, } +/// Update failures must be diagnosable from a user screenshot, so every cache +/// error names the step and the exact path instead of a bare OS error. +fn context(action: &str, path: &Path, error: impl std::fmt::Display) -> String { + format!("{action} ({}): {error}", path.display()) +} + fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> { - let mut file = fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| e.to_string())?; - file.write_all(bytes).and_then(|_| file.sync_all()).map_err(|e| e.to_string()) + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| context("Failed to create the DBX update cache file", path, error))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| context("Failed to write the DBX update cache file", path, error)) } pub(super) fn commit(root: &Path, record: &CacheRecord, bytes: &[u8]) -> Result<(), String> { @@ -40,15 +52,19 @@ pub(super) fn commit(root: &Path, record: &CacheRecord, bytes: &[u8]) -> Result< if metadata.len() > 4 * 1024 * 1024 || bytes.len() > 512 * 1024 * 1024 { return Err("Cached update exceeds size limits.".into()); } - fs::create_dir_all(root).map_err(|e| e.to_string())?; + fs::create_dir_all(root) + .map_err(|error| context("Failed to create the DBX update cache directory", root, error))?; let staging = root.join(format!("partial-{}", uuid::Uuid::new_v4())); - fs::create_dir(&staging).map_err(|e| e.to_string())?; + fs::create_dir(&staging) + .map_err(|error| context("Failed to create the DBX update cache staging directory", &staging, error))?; let result = (|| { write_synced(&staging.join("package"), bytes)?; write_synced(&staging.join("metadata.json"), &metadata)?; #[cfg(unix)] fs::File::open(&staging).and_then(|dir| dir.sync_all()).map_err(|e| e.to_string())?; - fs::rename(&staging, root.join("ready")).map_err(|e| e.to_string())?; + let ready = root.join("ready"); + fs::rename(&staging, &ready) + .map_err(|error| context("Failed to finalize the DBX update cache", &ready, error))?; #[cfg(unix)] fs::File::open(root).and_then(|dir| dir.sync_all()).map_err(|e| e.to_string())?; Ok(()) @@ -59,21 +75,38 @@ pub(super) fn commit(root: &Path, record: &CacheRecord, bytes: &[u8]) -> Result< result } +/// A cache that a security tool or a permission rule blocks is otherwise only +/// discovered mid-download, where it surfaces as a misleading download error. +pub(super) fn ensure_writable(root: &Path) -> Result<(), String> { + fs::create_dir_all(root) + .map_err(|error| context("Failed to create the DBX update cache directory", root, error))?; + let probe = root.join(format!("probe-{}", uuid::Uuid::new_v4())); + fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&probe) + .map_err(|error| context("The DBX update cache directory is not writable", root, error))?; + fs::remove_file(&probe) + .map_err(|error| context("Failed to clean up the DBX update cache probe file", &probe, error))?; + Ok(()) +} + pub(super) fn digest(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn read_bounded(path: &Path, limit: u64) -> Result, String> { - let metadata = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| context("Failed to inspect the DBX update cache file", path, error))?; if !metadata.file_type().is_file() || metadata.len() > limit { return Err("Invalid cached update file.".into()); } let mut bytes = Vec::new(); fs::File::open(path) - .map_err(|e| e.to_string())? + .map_err(|error| context("Failed to open the DBX update cache file", path, error))? .take(limit + 1) .read_to_end(&mut bytes) - .map_err(|e| e.to_string())?; + .map_err(|error| context("Failed to read the DBX update cache file", path, error))?; if bytes.len() as u64 > limit { return Err("Cached update file exceeds limit.".into()); } @@ -85,7 +118,7 @@ pub(super) fn read(root: &Path) -> Result)>, String let metadata = match fs::symlink_metadata(&ready) { Ok(metadata) => metadata, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(e.to_string()), + Err(e) => return Err(context("Failed to inspect the DBX update cache", &ready, e)), }; if !metadata.file_type().is_dir() { return Err("Invalid cached update directory.".into()); @@ -107,15 +140,18 @@ pub(super) fn cleanup_partial(root: &Path) -> Result<(), String> { let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => return Err(e.to_string()), + Err(e) => return Err(context("Failed to read the DBX update cache directory", root, e)), }; for entry in entries { - let entry = entry.map_err(|e| e.to_string())?; + let entry = + entry.map_err(|error| context("Failed to enumerate the DBX update cache directory", root, error))?; if entry.file_name().to_string_lossy().starts_with("partial-") { - if entry.file_type().map_err(|e| e.to_string())?.is_dir() { - fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?; + let path = entry.path(); + let action = "Failed to remove an incomplete DBX update cache entry"; + if entry.file_type().map_err(|error| context(action, &path, error))?.is_dir() { + fs::remove_dir_all(&path).map_err(|error| context(action, &path, error))?; } else { - fs::remove_file(entry.path()).map_err(|e| e.to_string())?; + fs::remove_file(&path).map_err(|error| context(action, &path, error))?; } } } @@ -125,14 +161,14 @@ pub(super) fn cleanup_partial(root: &Path) -> Result<(), String> { pub(super) fn discard(root: &Path) -> Result<(), String> { let ready = root.join("ready"); let result = if fs::symlink_metadata(&ready).is_ok_and(|m| !m.file_type().is_dir()) { - fs::remove_file(ready) + fs::remove_file(&ready) } else { - fs::remove_dir_all(ready) + fs::remove_dir_all(&ready) }; match result { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e.to_string()), + Err(e) => Err(context("Failed to clear the previous DBX update cache", &ready, e)), } } @@ -144,6 +180,27 @@ pub(super) fn root(app: &tauri::AppHandle) -> Result { #[cfg(test)] mod tests { use super::*; + + // User screenshots only carry the message, so a cache failure has to name + // the step and the exact path to be actionable. + #[test] + fn cache_errors_name_the_step_and_the_path() { + let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + fs::write(&root, b"not a directory").unwrap(); + let error = ensure_writable(&root).unwrap_err(); + assert!(error.contains("update cache"), "{error}"); + assert!(error.contains(&root.display().to_string()), "{error}"); + fs::remove_file(&root).unwrap(); + } + + #[test] + fn writable_cache_passes_the_preflight_without_leftovers() { + let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + ensure_writable(&root).unwrap(); + assert_eq!(fs::read_dir(&root).unwrap().count(), 0); + fs::remove_dir_all(&root).unwrap(); + } + #[cfg(unix)] #[test] fn rejects_symlink_without_deleting_its_target() { diff --git a/src-tauri/src/commands/update_portable.rs b/src-tauri/src/commands/update_portable.rs index f544869aaf..7cba74b43a 100644 --- a/src-tauri/src/commands/update_portable.rs +++ b/src-tauri/src/commands/update_portable.rs @@ -170,6 +170,19 @@ fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() } +/// Windows PowerShell lives in a versioned directory that Rust's executable +/// search order never visits (it only probes the `System32` root), so a machine +/// whose `PATH` lost that entry fails with "program not found" even though +/// PowerShell is installed. Resolve the absolute path first and keep the bare +/// name as a fallback for non-standard installs. +#[cfg(target_os = "windows")] +fn resolve_powershell_program() -> Option { + let root = std::env::var_os("SystemRoot").filter(|value| !value.is_empty())?; + let candidate = + std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); + candidate.is_file().then_some(candidate) +} + #[cfg(target_os = "windows")] pub(super) fn launch_portable_update_helper(archive: &[u8], version: &Version) -> Result<(), String> { use std::fs::{self, OpenOptions}; @@ -223,7 +236,9 @@ pub(super) fn launch_portable_update_helper(archive: &[u8], version: &Version) - fs::write(&script_path, PORTABLE_UPDATE_SCRIPT) .map_err(|error| format!("Failed to create portable update helper: {error}"))?; - Command::new("powershell.exe") + let resolved_powershell = resolve_powershell_program(); + let program = resolved_powershell.clone().unwrap_or_else(|| std::path::PathBuf::from("powershell.exe")); + Command::new(&program) .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File"]) .arg(&script_path) .arg("-ParentProcessId") @@ -238,7 +253,12 @@ pub(super) fn launch_portable_update_helper(archive: &[u8], version: &Version) - .arg(&staging_dir) .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) .spawn() - .map_err(|error| format!("Failed to start portable update helper: {error}"))?; + .map_err(|error| match resolved_powershell.as_deref() { + Some(resolved) => format!("Failed to start portable update helper ({}): {error}", resolved.display()), + None => format!( + "Failed to start portable update helper: {error}. Portable updates replace DBX.exe with Windows PowerShell, which was not found at %SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe or on PATH; install it or download the portable ZIP from the release page to update manually." + ), + })?; Ok(()) })();