feat: sha256 checksum for self-updates

This commit is contained in:
zhom
2026-07-11 15:41:00 +04:00
parent 86d58717b4
commit eeb5c816bf
15 changed files with 418 additions and 14 deletions
+23
View File
@@ -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]
+30 -3
View File
@@ -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
+311 -1
View File
@@ -87,6 +87,10 @@ pub struct AppReleaseAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
/// GitHub-computed digest ("sha256:<hex>"); absent on assets uploaded
/// before GitHub started calculating digests.
#[serde(default)]
pub digest: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -111,6 +115,15 @@ pub struct AppUpdateInfo {
pub release_page_url: Option<String>,
/// 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<String>,
/// GitHub's server-side digest of the chosen asset ("sha256:<hex>"),
/// cross-checked in addition to SHA256SUMS.txt when present.
#[serde(default)]
pub asset_digest: Option<String>,
}
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<String> {
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
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
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<dyn std::error::Error + Send + Sync>> {
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,
},
];
+13 -1
View File
@@ -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);
},
);
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -1798,7 +1798,9 @@
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。"
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。",
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。"
},
"rail": {
"profiles": "プロファイル",
+3 -1
View File
@@ -1798,7 +1798,9 @@
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요."
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요.",
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요."
},
"rail": {
"profiles": "프로필",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -1798,7 +1798,9 @@
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern."
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern.",
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз."
},
"rail": {
"profiles": "Профили",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -1798,7 +1798,9 @@
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。"
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。",
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。"
},
"rail": {
"profiles": "配置文件",
+10
View File
@@ -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 ?? "",
+4
View File
@@ -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:<hex>"). */
asset_digest?: string | null;
}
export interface AppUpdateProgress {