mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 13:50:26 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443f8b4597 | ||
|
|
e0b6504e9e | ||
|
|
6d3f2c6cbf |
@@ -1,6 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.29.6 (2026-08-24)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- switch to svg
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: linting
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- test: integration cleanup
|
||||
- chore: switch to gitdebt
|
||||
- chore: update flake.nix for v0.29.5 [skip ci] (#563)
|
||||
|
||||
|
||||
## v0.29.5 (2026-08-16)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.29.5";
|
||||
releaseVersion = "0.29.6";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage";
|
||||
hash = "sha256-/zOFGaHTk4S53DLZe2BXmILe/P1dlb9dT94hyvEqXcM=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage";
|
||||
hash = "sha256-tZtULAhKl0HaiKj4WUZX4pkzjbPtOS/rioXPjrrm4Xk=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage";
|
||||
hash = "sha256-w2xibn/GZq681vZDj9pYNOrwhnqxX0POhmBO8XMFb+c=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage";
|
||||
hash = "sha256-5FWZeECixlNNxHuGzKoNxmHDjNtePU0yg0bgDLD1s5c=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
@@ -768,42 +768,6 @@ impl AppAutoUpdater {
|
||||
.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
|
||||
@@ -857,7 +821,7 @@ impl AppAutoUpdater {
|
||||
}
|
||||
};
|
||||
|
||||
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
|
||||
let Some(expected) = crate::checksum::find_checksum_for_file(&checksums_text, filename) else {
|
||||
log::warn!(
|
||||
"No checksum entry for {filename} in {}",
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
@@ -877,7 +841,7 @@ impl AppAutoUpdater {
|
||||
expected: &str,
|
||||
asset_digest: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let actual = Self::sha256_file(file_path)?;
|
||||
let actual = crate::checksum::sha256_file(file_path)?;
|
||||
|
||||
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
|
||||
|
||||
@@ -2226,48 +2190,6 @@ 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![
|
||||
|
||||
@@ -25,6 +25,14 @@ impl BrowserType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand name for user-facing strings. `as_str` is the internal id and is
|
||||
/// the wrong thing to put in a message the user reads.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
BrowserType::Wayfern => "Wayfern",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s {
|
||||
"wayfern" => Ok(BrowserType::Wayfern),
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! SHA256 helpers shared by the app self-updater and the browser downloader.
|
||||
//!
|
||||
//! Both verify a downloaded artifact against a digest published beside it, so
|
||||
//! the hashing and the `sha256sum` parsing live here instead of in either
|
||||
//! caller.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Stream `path` through SHA256 and return the lowercase hex digest. Reads in
|
||||
/// 1 MiB blocks so a multi-gigabyte browser archive never lands in memory.
|
||||
pub 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 = std::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)
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
pub 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 && is_sha256_hex(hash) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Digest from a single-asset `<file>.sha256` sidecar.
|
||||
///
|
||||
/// Prefers the entry named `filename`, because a name binds the digest to the
|
||||
/// asset it covers. Falls back to a lone digest only when the sidecar holds
|
||||
/// exactly one: `sha256sum < file` writes `-` as the name and some publishers
|
||||
/// emit the bare hash, and neither is ambiguous when it stands alone. A
|
||||
/// sidecar listing several assets always needs the name to match.
|
||||
pub fn parse_sidecar_digest(text: &str, filename: &str) -> Option<String> {
|
||||
if let Some(named) = find_checksum_for_file(text, filename) {
|
||||
return Some(named);
|
||||
}
|
||||
|
||||
let mut digests = text
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().next())
|
||||
.filter(|token| is_sha256_hex(token));
|
||||
let only = digests.next()?;
|
||||
if digests.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(only.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_sha256_hex(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HELLO_WORLD_SHA256: &str =
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||
|
||||
#[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!(
|
||||
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!(
|
||||
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!(find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), None);
|
||||
// Missing file.
|
||||
assert_eq!(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!(sha256_file(&path).unwrap(), HELLO_WORLD_SHA256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_prefers_the_named_entry() {
|
||||
// The real Wayfern sidecar shape: `<hex> <name>`, one asset per file.
|
||||
let sidecar = format!("{HELLO_WORLD_SHA256} wayfern-151.0.7922.71_windows_x64.zip\n");
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern-151.0.7922.71_windows_x64.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_accepts_an_unnamed_lone_digest() {
|
||||
// `sha256sum < file` writes `-` as the name, and some publishers emit the
|
||||
// bare hash. Both cover the one asset the sidecar sits beside.
|
||||
for sidecar in [
|
||||
format!("{HELLO_WORLD_SHA256} -\n"),
|
||||
format!("{HELLO_WORLD_SHA256}\n"),
|
||||
format!(" {HELLO_WORLD_SHA256} \n"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256),
|
||||
"should accept lone digest in {sidecar:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_normalizes_case() {
|
||||
let sidecar = format!("{} -\n", HELLO_WORLD_SHA256.to_ascii_uppercase());
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_rejects_an_ambiguous_multi_entry_sidecar() {
|
||||
let sidecar = format!(
|
||||
"{HELLO_WORLD_SHA256} other.zip\n\
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 another.zip\n"
|
||||
);
|
||||
// Two candidates and neither is named `wayfern.zip`: guessing would defeat
|
||||
// the point of the check.
|
||||
assert_eq!(parse_sidecar_digest(&sidecar, "wayfern.zip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_rejects_junk() {
|
||||
assert_eq!(parse_sidecar_digest("", "wayfern.zip"), None);
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("not-a-hash wayfern.zip", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
// An HTML error page served with HTTP 200 must not read as a digest.
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("<!doctype html><title>404</title>", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
// Right shape, wrong length.
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("abc123 wayfern.zip", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
+359
-6
@@ -15,6 +15,10 @@ use crate::events;
|
||||
// the UI can surface it and the caller can move on / retry.
|
||||
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
// Sent on both the asset request and its checksum sidecar so the CDN sees one
|
||||
// consistent client for the pair.
|
||||
const DOWNLOAD_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
|
||||
// Global state to track currently downloading browser-version pairs
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
||||
@@ -233,6 +237,113 @@ impl Downloader {
|
||||
.await?;
|
||||
log::info!("Download URL resolved");
|
||||
|
||||
// Every browser asset is published with a `<asset>.sha256` sidecar. Fetch
|
||||
// it before the transfer starts: an asset nobody can verify costs one small
|
||||
// request to reject here, or a wasted gigabyte to reject later.
|
||||
let expected_sha256 = self
|
||||
.fetch_expected_archive_checksum(&download_url, browser_type.display_name(), version)
|
||||
.await?;
|
||||
|
||||
let file_path = self
|
||||
.stream_download(
|
||||
browser_type.clone(),
|
||||
version,
|
||||
&download_url,
|
||||
file_path,
|
||||
cancel_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Hashing a multi-gigabyte archive takes seconds, so tell the UI what the
|
||||
// pause is for instead of leaving the bar sitting at 100%.
|
||||
let _ = events::emit(
|
||||
"download-progress",
|
||||
&DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
version: version.to_string(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 100.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "verifying".to_string(),
|
||||
},
|
||||
);
|
||||
verify_archive_checksum(
|
||||
&file_path,
|
||||
&expected_sha256,
|
||||
browser_type.display_name(),
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Fetch `<asset>.sha256` and return the digest it publishes for the asset.
|
||||
/// Every failure mode maps to `BROWSER_CHECKSUM_UNAVAILABLE`; the specifics
|
||||
/// go to the log.
|
||||
async fn fetch_expected_archive_checksum(
|
||||
&self,
|
||||
download_url: &str,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
|
||||
serde_json::json!({
|
||||
"code": "BROWSER_CHECKSUM_UNAVAILABLE",
|
||||
"params": { "browser": browser, "version": version }
|
||||
})
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
|
||||
let sidecar_url = checksum_sidecar_url(download_url);
|
||||
let response = match self
|
||||
.client
|
||||
.get(&sidecar_url)
|
||||
.header("User-Agent", DOWNLOAD_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => response,
|
||||
Ok(response) => {
|
||||
log::warn!(
|
||||
"Checksum sidecar request failed for {browser} {version}: HTTP {}",
|
||||
response.status()
|
||||
);
|
||||
return Err(unavailable());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksum sidecar request failed for {browser} {version}: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let sidecar_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read the checksum sidecar for {browser} {version}: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let asset_name = asset_filename_from_url(download_url);
|
||||
let Some(expected) = crate::checksum::parse_sidecar_digest(&sidecar_text, asset_name) else {
|
||||
log::warn!("No usable digest for {asset_name} in {sidecar_url}");
|
||||
return Err(unavailable());
|
||||
};
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
async fn stream_download(
|
||||
&self,
|
||||
browser_type: BrowserType,
|
||||
version: &str,
|
||||
download_url: &str,
|
||||
file_path: PathBuf,
|
||||
cancel_token: Option<&CancellationToken>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// In-session resume: a large (~1GB) download over a flaky connection can
|
||||
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
|
||||
// terminal failure (which forces the user to re-click and risks the CDN
|
||||
@@ -257,11 +368,8 @@ impl Downloader {
|
||||
for attempt in 0..=max_send_retries {
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_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",
|
||||
);
|
||||
.get(download_url)
|
||||
.header("User-Agent", DOWNLOAD_USER_AGENT);
|
||||
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
@@ -682,7 +790,7 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(format!("Failed to download browser: {e}").into());
|
||||
return Err(contextualize("Failed to download browser", e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -872,6 +980,74 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset of a URL's query or fragment, or its length when it has neither.
|
||||
fn url_path_end(url: &str) -> usize {
|
||||
url.find(['?', '#']).unwrap_or(url.len())
|
||||
}
|
||||
|
||||
/// The `<asset>.sha256` published next to every browser asset. Any query or
|
||||
/// fragment stays at the end so a signed URL keeps working.
|
||||
fn checksum_sidecar_url(download_url: &str) -> String {
|
||||
let (base, suffix) = download_url.split_at(url_path_end(download_url));
|
||||
format!("{base}.sha256{suffix}")
|
||||
}
|
||||
|
||||
/// Last path segment of `url`. This is the name a `sha256sum` sidecar records,
|
||||
/// and it is not the local filename: the local one is built from the running
|
||||
/// platform, while this one is whatever the publisher called the asset.
|
||||
fn asset_filename_from_url(url: &str) -> &str {
|
||||
url[..url_path_end(url)].rsplit('/').next().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Compare the finished archive against the digest published beside it. A
|
||||
/// mismatch means the bytes on disk are not the asset the manifest promised,
|
||||
/// so the file is deleted instead of being handed to the extractor.
|
||||
async fn verify_archive_checksum(
|
||||
file_path: &Path,
|
||||
expected: &str,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash_path = file_path.to_path_buf();
|
||||
let actual = tokio::task::spawn_blocking(move || crate::checksum::sha256_file(&hash_path))
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Checksum task failed: {e}").into()
|
||||
})??;
|
||||
|
||||
if actual.eq_ignore_ascii_case(expected) {
|
||||
log::info!("Checksum verified for {browser} {version}: {actual}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::error!("Checksum mismatch for {browser} {version}: expected {expected}, got {actual}");
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
Err(
|
||||
serde_json::json!({
|
||||
"code": "BROWSER_CHECKSUM_MISMATCH",
|
||||
"params": { "browser": browser, "version": version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Prefix the calling context onto a bare message, but leave an already-coded
|
||||
/// backend error alone: `wrap_backend_error` only passes a payload through
|
||||
/// when it still starts with `{`, so prefixing one would strip the code and
|
||||
/// the frontend would fall back to the untranslated INTERNAL_ERROR text.
|
||||
fn contextualize(
|
||||
context: &str,
|
||||
e: impl std::fmt::Display,
|
||||
) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg.into()
|
||||
} else {
|
||||
format!("{context}: {msg}").into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific browser-version pair is currently being downloaded
|
||||
pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
let download_key = format!("{browser}-{version}");
|
||||
@@ -1040,6 +1216,183 @@ mod tests {
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
|
||||
// Stand-in archive body for the checksum tests. Digests are computed from
|
||||
// it rather than hardcoded, so the fixture and the assertion cannot drift.
|
||||
const ARCHIVE_BODY: &[u8] = b"wayfern archive bytes";
|
||||
|
||||
fn digest_of(bytes: &[u8]) -> String {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("archive.zip");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
crate::checksum::sha256_file(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checksum_sidecar_url_appends_to_the_asset_path() {
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://download.wayfern.com/wayfern-151_windows_x64.zip"),
|
||||
"https://download.wayfern.com/wayfern-151_windows_x64.zip.sha256"
|
||||
);
|
||||
// A signed URL keeps its query, so the sidecar stays reachable.
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://cdn.example.com/a.zip?token=abc&exp=1"),
|
||||
"https://cdn.example.com/a.zip.sha256?token=abc&exp=1"
|
||||
);
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://cdn.example.com/a.zip#frag"),
|
||||
"https://cdn.example.com/a.zip.sha256#frag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_filename_from_url_takes_the_publisher_name() {
|
||||
// Deliberately different from the local filename, which is built from the
|
||||
// running platform and would never match a sidecar entry.
|
||||
assert_eq!(
|
||||
asset_filename_from_url("https://download.wayfern.com/wayfern-151.0.7922.71_windows_x64.zip"),
|
||||
"wayfern-151.0.7922.71_windows_x64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
asset_filename_from_url("https://cdn.example.com/dir/a.tar.xz?token=abc"),
|
||||
"a.tar.xz"
|
||||
);
|
||||
assert_eq!(asset_filename_from_url("https://cdn.example.com/"), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_reads_the_sidecar() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
let digest = digest_of(ARCHIVE_BODY);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!("{digest} wayfern-151_windows_x64.zip\n")),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let expected = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect("sidecar should resolve");
|
||||
|
||||
assert_eq!(expected, digest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_fails_when_the_sidecar_is_missing() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let error = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect_err("an unverifiable asset must not be downloaded")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_UNAVAILABLE") && error.contains("151"),
|
||||
"expected a coded, translatable error, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_rejects_a_sidecar_without_a_digest() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
// A CDN that answers 200 with an error page must not be read as a digest.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<!doctype html><title>404</title>"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let error = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect_err("an unparsable sidecar must not pass")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_UNAVAILABLE"),
|
||||
"expected a coded error, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_archive_checksum_accepts_a_matching_digest() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let archive = temp_dir.path().join("wayfern.zip");
|
||||
std::fs::write(&archive, ARCHIVE_BODY).unwrap();
|
||||
let digest = crate::checksum::sha256_file(&archive).unwrap();
|
||||
|
||||
// Case is normalized on both sides, so an uppercase sidecar still matches.
|
||||
verify_archive_checksum(
|
||||
&archive,
|
||||
&digest.to_ascii_uppercase(),
|
||||
"wayfern",
|
||||
"151.0.7922.71",
|
||||
)
|
||||
.await
|
||||
.expect("a matching digest should verify");
|
||||
|
||||
assert!(archive.exists(), "a verified archive must be kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_archive_checksum_rejects_and_deletes_a_mismatch() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let archive = temp_dir.path().join("wayfern.zip");
|
||||
// The file on disk is the wrong asset entirely, which is what a mislinked
|
||||
// manifest slot delivers, while the sidecar describes the right one.
|
||||
std::fs::write(&archive, b"a macOS disk image, not a windows zip").unwrap();
|
||||
let expected = digest_of(ARCHIVE_BODY);
|
||||
|
||||
let error = verify_archive_checksum(&archive, &expected, "wayfern", "151.0.7922.71")
|
||||
.await
|
||||
.expect_err("a mismatched digest must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_MISMATCH") && error.contains("151.0.7922.71"),
|
||||
"expected a coded, translatable error, got: {error}"
|
||||
);
|
||||
assert!(
|
||||
!archive.exists(),
|
||||
"an archive that failed verification must be deleted, not extracted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contextualize_preserves_a_coded_backend_error() {
|
||||
// A coded payload must survive untouched: wrap_backend_error only passes
|
||||
// it through while it still starts with '{'.
|
||||
let coded = r#"{"code":"BROWSER_CHECKSUM_MISMATCH","params":{"browser":"wayfern"}}"#;
|
||||
assert_eq!(
|
||||
contextualize("Failed to download browser", coded).to_string(),
|
||||
coded
|
||||
);
|
||||
|
||||
// A bare message still gets its context.
|
||||
assert_eq!(
|
||||
contextualize("Failed to download browser", "connection reset").to_string(),
|
||||
"Failed to download browser: connection reset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_download_state_for_browser_removes_stuck_keys() {
|
||||
// Simulate a download future that was abandoned without running its own cleanup,
|
||||
|
||||
@@ -62,6 +62,7 @@ mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
mod cdp_target;
|
||||
mod checksum;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
|
||||
@@ -1890,6 +1890,8 @@
|
||||
"camoufoxImportDeprecated": "Importing this profile type 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.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} could not be verified because its checksum file could not be retrieved. The download was stopped; it will be retried later.",
|
||||
"browserChecksumMismatch": "The downloaded {{browser}} {{version}} archive failed checksum verification and was discarded. Please try again.",
|
||||
"nameCannotBeEmpty": "Name cannot be empty",
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}.",
|
||||
"profileNameExists": "A profile named \"{{name}}\" already exists",
|
||||
|
||||
@@ -1896,6 +1896,8 @@
|
||||
"camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza 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.",
|
||||
"browserChecksumUnavailable": "No se pudo verificar {{browser}} {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La descarga se detuvo; se reintentará más tarde.",
|
||||
"browserChecksumMismatch": "El archivo descargado de {{browser}} {{version}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"nameCannotBeEmpty": "El nombre no puede estar vacío",
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}.",
|
||||
"profileNameExists": "Ya existe un perfil llamado \"{{name}}\"",
|
||||
|
||||
@@ -1896,6 +1896,8 @@
|
||||
"camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser 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.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} n'a pas pu être vérifié car son fichier de sommes de contrôle n'a pas pu être récupéré. Le téléchargement a été interrompu ; une nouvelle tentative aura lieu plus tard.",
|
||||
"browserChecksumMismatch": "L'archive {{browser}} {{version}} téléchargée a échoué à la vérification de la somme de contrôle et a été supprimée. Veuillez réessayer.",
|
||||
"nameCannotBeEmpty": "Le nom ne peut pas être vide",
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}.",
|
||||
"profileNameExists": "Un profil nommé « {{name}} » existe déjà",
|
||||
|
||||
@@ -1889,6 +1889,8 @@
|
||||
"camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。",
|
||||
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。ダウンロードは中止されました。後で再試行されます。",
|
||||
"browserChecksumMismatch": "ダウンロードした {{browser}} {{version}} のアーカイブはチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"nameCannotBeEmpty": "名前を空にすることはできません",
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。",
|
||||
"profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します",
|
||||
|
||||
@@ -1889,6 +1889,8 @@
|
||||
"camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.",
|
||||
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 다운로드가 중단되었으며 나중에 다시 시도됩니다.",
|
||||
"browserChecksumMismatch": "다운로드한 {{browser}} {{version}} 아카이브가 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.",
|
||||
"profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다",
|
||||
|
||||
@@ -1896,6 +1896,8 @@
|
||||
"camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.",
|
||||
"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.",
|
||||
"browserChecksumUnavailable": "Não foi possível verificar o {{browser}} {{version}} porque o arquivo de somas de verificação não pôde ser obtido. O download foi interrompido; será tentado novamente mais tarde.",
|
||||
"browserChecksumMismatch": "O arquivo baixado do {{browser}} {{version}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"nameCannotBeEmpty": "O nome não pode estar vazio",
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}.",
|
||||
"profileNameExists": "Já existe um perfil chamado \"{{name}}\"",
|
||||
|
||||
@@ -1903,6 +1903,8 @@
|
||||
"camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.",
|
||||
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"browserChecksumUnavailable": "Не удалось проверить {{browser}} {{version}}: файл контрольных сумм не удалось получить. Загрузка остановлена; попытка будет повторена позже.",
|
||||
"browserChecksumMismatch": "Загруженный архив {{browser}} {{version}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"nameCannotBeEmpty": "Имя не может быть пустым",
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.",
|
||||
"profileNameExists": "Профиль с именем «{{name}}» уже существует",
|
||||
|
||||
@@ -1889,6 +1889,8 @@
|
||||
"camoufoxImportDeprecated": "Bu profil türünün içe aktarılması artık desteklenmiyor. Lütfen bunun yerine Wayfern kullanın.",
|
||||
"updateChecksumsUnavailable": "{{version}} güncellemesi doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. Güncelleme yüklenmedi; daha sonra yeniden denenecek.",
|
||||
"updateChecksumMismatch": "İndirilen güncelleme dosyası {{file}} sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. İndirme durduruldu; daha sonra yeniden denenecek.",
|
||||
"browserChecksumMismatch": "İndirilen {{browser}} {{version}} arşivi sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
|
||||
"nameCannotBeEmpty": "Ad boş olamaz",
|
||||
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}.",
|
||||
"profileNameExists": "\"{{name}}\" adlı bir profil zaten var",
|
||||
|
||||
@@ -1889,6 +1889,8 @@
|
||||
"camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.",
|
||||
"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.",
|
||||
"browserChecksumUnavailable": "Không thể xác minh {{browser}} {{version}} vì không thể tải tệp checksum. Quá trình tải xuống đã dừng; sẽ thử lại sau.",
|
||||
"browserChecksumMismatch": "Kho lưu trữ {{browser}} {{version}} đã tải xuống không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"nameCannotBeEmpty": "Tên không được để trống",
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}.",
|
||||
"profileNameExists": "Hồ sơ có tên \"{{name}}\" đã tồn tại",
|
||||
|
||||
@@ -1889,6 +1889,8 @@
|
||||
"camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。",
|
||||
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
|
||||
"browserChecksumUnavailable": "无法验证 {{browser}} {{version}}:无法获取其校验和文件。下载已停止,稍后将重试。",
|
||||
"browserChecksumMismatch": "下载的 {{browser}} {{version}} 压缩包未通过校验和验证,已被丢弃。请重试。",
|
||||
"nameCannotBeEmpty": "名称不能为空",
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。",
|
||||
"profileNameExists": "名为“{{name}}”的配置文件已存在",
|
||||
|
||||
@@ -48,6 +48,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_SIDECAR_VERSION_MISMATCH"
|
||||
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||
| "UPDATE_CHECKSUM_MISMATCH"
|
||||
| "BROWSER_CHECKSUM_UNAVAILABLE"
|
||||
| "BROWSER_CHECKSUM_MISMATCH"
|
||||
| "UPDATE_PROFILES_RUNNING"
|
||||
| "UPDATE_PREPARATION_FAILED"
|
||||
| "PROFILE_NAME_EXISTS"
|
||||
@@ -287,6 +289,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.updateChecksumMismatch", {
|
||||
file: parsed.params?.file ?? "",
|
||||
});
|
||||
case "BROWSER_CHECKSUM_UNAVAILABLE":
|
||||
return t("backendErrors.browserChecksumUnavailable", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "BROWSER_CHECKSUM_MISMATCH":
|
||||
return t("backendErrors.browserChecksumMismatch", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "UPDATE_PROFILES_RUNNING":
|
||||
return t("backendErrors.updateProfilesRunning");
|
||||
case "UPDATE_PREPARATION_FAILED":
|
||||
|
||||
Reference in New Issue
Block a user