diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d011b62..7aebff3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -280,6 +280,29 @@ jobs: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true rm -f $RUNNER_TEMP/build_certificate.p12 || true + # Runs after every matrix leg (including the portable ZIP upload) so the + # sums cover the complete, final asset set. The app self-updater refuses to + # install a release it cannot verify against this file. + checksums: + if: github.repository == 'zhom/donutbrowser' + needs: [release] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Generate and upload SHA256SUMS.txt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + ASSETS_DIR="/tmp/release-assets" + mkdir -p "$ASSETS_DIR" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir "$ASSETS_DIR" + cd "$ASSETS_DIR" + sha256sum Donut* > SHA256SUMS.txt + cat SHA256SUMS.txt + gh release upload "$TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" + changelog: if: github.repository == 'zhom/donutbrowser' needs: [release] diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 76cdaea..f23c496 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -5,6 +5,14 @@ on: branches: - main +# Serialize runs: the rolling `nightly` release is deleted and recreated at the +# end of each run, and overlapping runs could interleave those steps (or leave +# a checksums file describing another run's assets). Queue instead of cancel so +# an in-flight delete/create is never aborted halfway. +concurrency: + group: rolling-release + cancel-in-progress: false + permissions: contents: write security-events: write @@ -210,7 +218,11 @@ jobs: id: timestamp shell: bash run: | - TIMESTAMP=$(date -u +"%Y-%m-%d") + # Committer date, not wall clock: every job in this run (including + # update-nightly-release, which runs much later) must derive the + # exact same tag, or a run straddling midnight UTC splits the + # release from its checksums. + TIMESTAMP=$(git show -s --format=%cs HEAD) COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7) echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}" @@ -289,7 +301,9 @@ jobs: - name: Generate nightly tag id: tag run: | - TIMESTAMP=$(date -u +"%Y-%m-%d") + # Committer date — must match the tag the build matrix computed (see + # the timestamp step there), even when this job runs past midnight. + TIMESTAMP=$(git show -s --format=%cs HEAD) COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7) echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT @@ -355,8 +369,16 @@ jobs: mkdir -p "$ASSETS_DIR" gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber - # Rename versioned filenames to stable nightly names + # Checksums for the per-commit release (original filenames). The app + # self-updater downloads from per-commit nightly releases and refuses + # to install anything it cannot verify against this file. + # --repo is required: ASSETS_DIR is outside the git checkout, so gh + # cannot infer the repository from the working directory. cd "$ASSETS_DIR" + sha256sum Donut* > SHA256SUMS.txt + gh release upload "$NIGHTLY_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" + + # Rename versioned filenames to stable nightly names for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done @@ -368,6 +390,10 @@ jobs: for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done for f in Donut_*_aarch64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_aarch64.app.tar.gz; done for f in Donut_*_x64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_x64.app.tar.gz; done + + # Checksums for the rolling release (renamed filenames), restricted + # to exactly the assets uploaded below. + sha256sum Donut_nightly_* Donut_aarch64.app.tar.gz Donut_x64.app.tar.gz > SHA256SUMS.txt cd "$GITHUB_WORKSPACE" # Delete existing rolling nightly release and tag @@ -379,6 +405,7 @@ jobs: "$ASSETS_DIR"/Donut_nightly_* \ "$ASSETS_DIR"/Donut_aarch64.app.tar.gz \ "$ASSETS_DIR"/Donut_x64.app.tar.gz \ + "$ASSETS_DIR"/SHA256SUMS.txt \ --title "Donut Browser Nightly" \ --notes-file /tmp/nightly-notes.md \ --prerelease diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 3c3bf37..14daf14 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -87,6 +87,10 @@ pub struct AppReleaseAsset { pub name: String, pub browser_download_url: String, pub size: u64, + /// GitHub-computed digest ("sha256:"); absent on assets uploaded + /// before GitHub started calculating digests. + #[serde(default)] + pub digest: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -111,6 +115,15 @@ pub struct AppUpdateInfo { pub release_page_url: Option, /// True when a system package manager repo is configured (apt/dnf/zypper) pub repo_update: bool, + /// URL of the release's SHA256SUMS.txt asset. The downloaded update is + /// verified against it before installation; without it the update is + /// refused. + #[serde(default)] + pub checksums_url: Option, + /// GitHub's server-side digest of the chosen asset ("sha256:"), + /// cross-checked in addition to SHA256SUMS.txt when present. + #[serde(default)] + pub asset_digest: Option, } pub struct AppAutoUpdater { @@ -214,6 +227,35 @@ impl AppAutoUpdater { // Find the appropriate asset for current platform let download_url = self.get_download_url_for_platform(&latest_release.assets); + // Locate the release's checksums file and the chosen asset's + // GitHub-computed digest for post-download verification. + let checksums_url = Self::find_checksums_url(&latest_release.assets); + let asset_digest = download_url.as_deref().and_then(|url| { + latest_release + .assets + .iter() + .find(|a| a.browser_download_url == url) + .and_then(|a| a.digest.clone()) + }); + + // Both release workflows upload SHA256SUMS.txt only after every platform + // build finishes, so a release without it is still being assembled (or + // its pipeline broke). Downloading now is guaranteed to fail closed, so + // treat the release as not ready and retry on a later check instead of + // surfacing an error for a healthy in-progress release. Applies only to + // the auto-download path — manual/repo notifications don't download. + let auto_download_possible = download_url.is_some(); + #[cfg(target_os = "linux")] + let auto_download_possible = auto_download_possible && !self.is_repo_configured(); + if auto_download_possible && checksums_url.is_none() { + log::info!( + "Release {} has no {} yet; treating as not ready for auto-update", + latest_release.tag_name, + Self::CHECKSUMS_ASSET_NAME + ); + return Ok(None); + } + // On Linux, when a package repo is configured, notify users to update via // their package manager instead of auto-downloading from GitHub. #[cfg(target_os = "linux")] @@ -230,6 +272,8 @@ impl AppAutoUpdater { manual_update_required, release_page_url: Some(release_page_url), repo_update, + checksums_url, + asset_digest, }; log::info!( @@ -255,6 +299,8 @@ impl AppAutoUpdater { manual_update_required: false, release_page_url: Some(release_page_url), repo_update: false, + checksums_url, + asset_digest, }; log::info!( @@ -712,6 +758,156 @@ impl AppAutoUpdater { None } + /// Name of the checksums asset both release workflows publish. + const CHECKSUMS_ASSET_NAME: &'static str = "SHA256SUMS.txt"; + + fn find_checksums_url(assets: &[AppReleaseAsset]) -> Option { + assets + .iter() + .find(|a| a.name == Self::CHECKSUMS_ASSET_NAME) + .map(|a| a.browser_download_url.clone()) + } + + /// Extract the hex digest for `filename` from standard `sha256sum` output + /// (` `, optionally with the `*` binary-mode marker). + fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option { + checksums_text.lines().find_map(|line| { + let (hash, rest) = line.split_once(char::is_whitespace)?; + let name = rest.trim_start().trim_start_matches('*'); + if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { + Some(hash.to_ascii_lowercase()) + } else { + None + } + }) + } + + fn sha256_file(path: &Path) -> Result> { + use sha2::{Digest, Sha256}; + use std::io::Read; + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1024 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(hex, "{byte:02x}"); + } + Ok(hex) + } + + /// Fetch the release's SHA256SUMS.txt and return the expected digest for + /// `filename`. Called BEFORE the (large) asset download so an unverifiable + /// release is rejected without wasting the transfer. Every failure mode + /// maps to the UPDATE_CHECKSUMS_UNAVAILABLE code; details go to the log. + async fn fetch_expected_checksum( + &self, + update_info: &AppUpdateInfo, + filename: &str, + ) -> Result> { + let unavailable = || -> Box { + serde_json::json!({ + "code": "UPDATE_CHECKSUMS_UNAVAILABLE", + "params": { "version": update_info.new_version } + }) + .to_string() + .into() + }; + + let Some(checksums_url) = update_info.checksums_url.as_deref() else { + log::warn!( + "No {} asset on release {}", + Self::CHECKSUMS_ASSET_NAME, + update_info.new_version + ); + return Err(unavailable()); + }; + + let response = match self + .client + .get(checksums_url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") + .send() + .await + { + Ok(response) if response.status().is_success() => response, + Ok(response) => { + log::warn!("Checksums file request failed: HTTP {}", response.status()); + return Err(unavailable()); + } + Err(e) => { + log::warn!("Checksums file request failed: {e}"); + return Err(unavailable()); + } + }; + + let checksums_text = match response.text().await { + Ok(text) => text, + Err(e) => { + log::warn!("Failed to read checksums file: {e}"); + return Err(unavailable()); + } + }; + + let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else { + log::warn!( + "No checksum entry for {filename} in {}", + Self::CHECKSUMS_ASSET_NAME + ); + return Err(unavailable()); + }; + Ok(expected) + } + + /// Verify the downloaded update against the expected SHA256SUMS.txt digest + /// (and GitHub's server-side asset digest when available) before anything + /// is extracted or installed. A corrupt download is deleted so the next + /// attempt starts fresh. + fn verify_update_checksum( + file_path: &Path, + filename: &str, + expected: &str, + asset_digest: Option<&str>, + ) -> Result<(), Box> { + let actual = Self::sha256_file(file_path)?; + + let mut mismatch = !actual.eq_ignore_ascii_case(expected); + + // Cross-check GitHub's server-side digest: SHA256SUMS.txt is computed by + // re-downloading assets in CI, so this catches corruption in that step. + if !mismatch { + if let Some(hex) = asset_digest.and_then(|d| d.strip_prefix("sha256:")) { + mismatch = !actual.eq_ignore_ascii_case(hex); + } + } + + if mismatch { + log::error!( + "Checksum mismatch for {filename}: expected {expected}, got {actual} (asset digest: {asset_digest:?})" + ); + let _ = fs::remove_file(file_path); + return Err( + serde_json::json!({ + "code": "UPDATE_CHECKSUM_MISMATCH", + "params": { "file": filename } + }) + .to_string() + .into(), + ); + } + + log::info!("Checksum verified for {filename}: {actual}"); + Ok(()) + } + /// Download the update file without progress tracking (silent download) async fn download_update_silent( &self, @@ -767,12 +963,24 @@ impl AppAutoUpdater { .unwrap_or("update.dmg") .to_string(); + // Resolve the expected checksum first so an unverifiable release is + // rejected before the multi-hundred-MB download, not after. + let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?; + log::info!("Downloading update from: {}", update_info.download_url); let download_path = self .download_update_silent(&update_info.download_url, &temp_dir, &filename) .await?; + log::info!("Verifying update checksum..."); + Self::verify_update_checksum( + &download_path, + &filename, + &expected_sha256, + update_info.asset_digest.as_deref(), + )?; + log::info!("Extracting update..."); let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; @@ -1765,7 +1973,16 @@ pub async fn download_and_prepare_app_update( updater .download_and_prepare_update(&app_handle, &update_info) .await - .map_err(|e| format!("Failed to download and prepare app update: {e}")) + .map_err(|e| { + let msg = e.to_string(); + // Structured error codes (`{"code": ...}`) must reach the frontend + // unwrapped so translateBackendError can resolve them. + if msg.starts_with('{') { + msg + } else { + format!("Failed to download and prepare app update: {msg}") + } + }) } #[tauri::command] @@ -1899,6 +2116,87 @@ mod tests { ); } + #[test] + fn test_find_checksum_for_file() { + let sums = "\ +0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg +ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg +not-a-hash Donut_0.29.0_amd64.deb +"; + + // Plain entry. + assert_eq!( + AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(), + Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11") + ); + // Binary-mode marker is stripped; hash is normalized to lowercase. + assert_eq!( + AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(), + Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22") + ); + // Entries with malformed hashes are rejected rather than trusted. + assert_eq!( + AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), + None + ); + // Missing file. + assert_eq!( + AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"), + None + ); + } + + #[test] + fn test_sha256_file_matches_known_digest() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let path = temp_dir.path().join("data.bin"); + std::fs::write(&path, b"hello world").unwrap(); + assert_eq!( + AppAutoUpdater::sha256_file(&path).unwrap(), + // sha256 of "hello world" + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn test_find_checksums_url() { + let assets = vec![ + AppReleaseAsset { + name: "Donut_0.29.0_x64.dmg".to_string(), + browser_download_url: "https://example.com/x64.dmg".to_string(), + size: 1, + digest: None, + }, + AppReleaseAsset { + name: "SHA256SUMS.txt".to_string(), + browser_download_url: "https://example.com/SHA256SUMS.txt".to_string(), + size: 1, + digest: None, + }, + ]; + assert_eq!( + AppAutoUpdater::find_checksums_url(&assets).as_deref(), + Some("https://example.com/SHA256SUMS.txt") + ); + assert_eq!(AppAutoUpdater::find_checksums_url(&assets[..1]), None); + } + + #[test] + fn test_release_asset_digest_is_optional_in_api_json() { + // Assets uploaded before GitHub started computing digests omit the field. + let without: AppReleaseAsset = serde_json::from_str( + r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5}"#, + ) + .expect("asset without digest should deserialize"); + assert_eq!(without.digest, None); + + let with: AppReleaseAsset = serde_json::from_str( + r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5, "digest": "sha256:ab12"}"#, + ) + .expect("asset with digest should deserialize"); + assert_eq!(with.digest.as_deref(), Some("sha256:ab12")); + } + #[test] fn test_platform_specific_download_urls() { let updater = AppAutoUpdater::instance(); @@ -1910,33 +2208,39 @@ mod tests { name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(), browser_download_url: "https://example.com/aarch64.dmg".to_string(), size: 12345, + digest: None, }, AppReleaseAsset { name: "Donut.Browser_0.1.0_x64.dmg".to_string(), browser_download_url: "https://example.com/x64.dmg".to_string(), size: 12345, + digest: None, }, // Windows assets (NSIS naming: _ARCH-setup.exe) AppReleaseAsset { name: "Donut_0.1.0_x64-setup.exe".to_string(), browser_download_url: "https://example.com/x64-setup.exe".to_string(), size: 12345, + digest: None, }, // Linux assets AppReleaseAsset { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, + digest: None, }, AppReleaseAsset { name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(), browser_download_url: "https://example.com/x86_64.rpm".to_string(), size: 12345, + digest: None, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, + digest: None, }, ]; @@ -2039,11 +2343,13 @@ mod tests { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, + digest: None, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, + digest: None, }, ]; @@ -2084,23 +2390,27 @@ mod tests { name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(), browser_download_url: "https://example.com/aarch64.dmg".to_string(), size: 12345, + digest: None, }, // Windows assets AppReleaseAsset { name: "Donut.Browser_0.1.0_x64.msi".to_string(), browser_download_url: "https://example.com/x64.msi".to_string(), size: 12345, + digest: None, }, // Linux assets AppReleaseAsset { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, + digest: None, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, + digest: None, }, ]; diff --git a/src/hooks/use-app-update-notifications.tsx b/src/hooks/use-app-update-notifications.tsx index fcfa8f7..a379dbc 100644 --- a/src/hooks/use-app-update-notifications.tsx +++ b/src/hooks/use-app-update-notifications.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { AppUpdateToast } from "@/components/app-update-toast"; +import { translateBackendError } from "@/lib/backend-errors"; import { showToast } from "@/lib/toast-utils"; import type { AppUpdateInfo, AppUpdateProgress } from "@/types"; @@ -82,11 +83,16 @@ export function useAppUpdateNotifications() { showToast({ type: "error", title: t("appUpdate.toast.updateFailed"), - description: String(error), + description: translateBackendError(t, error), duration: 6000, }); setIsUpdating(false); setUpdateProgress(null); + // Deliberately NOT resetting autoDownloadedVersion here: the + // auto-download effect re-runs as soon as isUpdating flips back to + // false, so clearing the marker now would retry in a tight loop. + // Retries are re-armed when the next backend check delivers a fresh + // update event instead. } }, [t], @@ -127,6 +133,12 @@ export function useAppUpdateNotifications() { "app-update-available", (event) => { console.log("App update available:", event.payload); + // A fresh backend check re-arms auto-download, so a version whose + // earlier attempt failed (e.g. a transient checksum-fetch error) is + // retried once per periodic check instead of staying blocked until + // restart. The effect's updateReady guard keeps an already-prepared + // update from being downloaded again. + autoDownloadedVersion.current = null; setUpdateInfo(event.payload); }, ); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9c1a1be..73efc56 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.", "proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.", "vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.", - "camoufoxImportDeprecated": "Importing Firefox-based profiles is no longer supported. Please use Wayfern instead." + "camoufoxImportDeprecated": "Importing Firefox-based profiles is no longer supported. Please use Wayfern instead.", + "updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.", + "updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 56a1f5b..f3a82cd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.", "proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.", "vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.", - "camoufoxImportDeprecated": "La importación de perfiles basados en Firefox ya no está soportada. Utilice Wayfern en su lugar." + "camoufoxImportDeprecated": "La importación de perfiles basados en Firefox ya no está soportada. Utilice Wayfern en su lugar.", + "updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.", + "updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4be932c..d231626 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.", "proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.", "vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.", - "camoufoxImportDeprecated": "L'importation de profils basés sur Firefox n'est plus prise en charge. Utilisez Wayfern à la place." + "camoufoxImportDeprecated": "L'importation de profils basés sur Firefox n'est plus prise en charge. Utilisez Wayfern à la place.", + "updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.", + "updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 26924f4..e95fdeb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。", "proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。", "vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。", - "camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。" + "camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。", + "updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。", + "updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 87b48cb..bc455d6 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.", "proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.", "vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.", - "camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요." + "camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요.", + "updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.", + "updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7e1bd2d..7fc193a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.", "proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.", "vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.", - "camoufoxImportDeprecated": "A importação de perfis baseados em Firefox não é mais suportada. Use o Wayfern." + "camoufoxImportDeprecated": "A importação de perfis baseados em Firefox não é mais suportada. Use o Wayfern.", + "updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.", + "updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 849cd9f..1994752 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.", "proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.", "vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.", - "camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern." + "camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern.", + "updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.", + "updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 32fd06f..69e13e3 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.", "proxyPaymentRequired": "Proxy đã chọn yêu cầu thanh toán (402) — gói đăng ký của nó có thể đã hết hạn — nên profile chưa được tạo.", "vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo.", - "camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox. Vui lòng dùng Wayfern." + "camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox. Vui lòng dùng Wayfern.", + "updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.", + "updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c3df3bc..aee84be 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1798,7 +1798,9 @@ "proxyNotWorking": "所选代理无法使用,因此未创建配置文件。", "proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。", "vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。", - "camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。" + "camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。", + "updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。", + "updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index db3820a..ed70186 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -34,6 +34,8 @@ export type BackendErrorCode = | "PROXY_PAYMENT_REQUIRED" | "VPN_NOT_WORKING" | "CAMOUFOX_IMPORT_DEPRECATED" + | "UPDATE_CHECKSUMS_UNAVAILABLE" + | "UPDATE_CHECKSUM_MISMATCH" | "INTERNAL_ERROR"; export interface BackendError { @@ -138,6 +140,14 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.vpnNotWorking"); case "CAMOUFOX_IMPORT_DEPRECATED": return t("backendErrors.camoufoxImportDeprecated"); + case "UPDATE_CHECKSUMS_UNAVAILABLE": + return t("backendErrors.updateChecksumsUnavailable", { + version: parsed.params?.version ?? "", + }); + case "UPDATE_CHECKSUM_MISMATCH": + return t("backendErrors.updateChecksumMismatch", { + file: parsed.params?.file ?? "", + }); case "INTERNAL_ERROR": return t("backendErrors.internal", { detail: parsed.params?.detail ?? "", diff --git a/src/types.ts b/src/types.ts index 10e0b66..9d40bad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -211,6 +211,10 @@ export interface AppUpdateInfo { manual_update_required: boolean; release_page_url?: string; repo_update: boolean; + /** URL of the release's SHA256SUMS.txt; downloads are verified against it. */ + checksums_url?: string | null; + /** GitHub-computed digest of the chosen asset ("sha256:"). */ + asset_digest?: string | null; } export interface AppUpdateProgress {