Compare commits

...
Author SHA1 Message Date
andy d346c134b0 Merge pull request #572 from zhom/dependabot/github_actions/github-actions-5bda34cb3d
ci(deps): bump the github-actions group with 5 updates
2026-08-25 13:42:16 -07:00
zhom 443f8b4597 feat: verify checksum for wayfern 2026-08-24 16:18:30 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> e0b6504e9e chore: update flake.nix for v0.29.6 [skip ci] (#576)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 00:34:06 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> 6d3f2c6cbf docs: update CHANGELOG.md and README.md for v0.29.6 [skip ci] (#575)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 00:33:42 +00:00
dependabot[bot] 21a835a942 ci(deps): bump the github-actions group with 5 updates
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.2.0` | `4.3.0` |
| [anomalyco/opencode/github](https://github.com/anomalyco/opencode) | `1.18.17` | `1.18.18` |
| [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) | `e97e2d8cc328f1b50210efc529dca0028893a2d9` | `6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.5.0` | `2.5.1` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.5.0` | `2.5.1` |


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `anomalyco/opencode/github` from 1.18.17 to 1.18.18
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/02546dfc2e4515a4f90aaf9ceb3890df2ac2b479...31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d)

Updates `dtolnay/rust-toolchain` from e97e2d8cc328f1b50210efc529dca0028893a2d9 to 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/e97e2d8cc328f1b50210efc529dca0028893a2d9...6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/8deb546fdb875b9996d27d4950be7312dac076a1...6e4298ebc4db23e847df9b2e2de2939d6f066c67)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/8deb546fdb875b9996d27d4950be7312dac076a1...6e4298ebc4db23e847df9b2e2de2939d6f066c67)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: dtolnay/rust-toolchain
  dependency-version: 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 09:08:59 +00:00
27 changed files with 622 additions and 107 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c #v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e #v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f #v4.6.0
+1 -1
View File
@@ -693,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
uses: anomalyco/opencode/github@31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d #v1.18.18
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
cache: "pnpm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
components: rustfmt, clippy
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
fetch-depth: 0
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
with:
toolchain: stable
targets: ${{ matrix.target }}
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
@@ -115,7 +115,7 @@ jobs:
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
@@ -122,7 +122,7 @@ jobs:
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
+21
View File
@@ -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
+5 -5
View File
@@ -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:
+5 -5
View File
@@ -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;
+2 -80
View File
@@ -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![
+8
View File
@@ -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),
+178
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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;
+2
View File
@@ -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",
+2
View File
@@ -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}}\"",
+2
View File
@@ -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à",
+2
View File
@@ -1889,6 +1889,8 @@
"camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。",
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
"browserChecksumUnavailable": "{{browser}} {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。ダウンロードは中止されました。後で再試行されます。",
"browserChecksumMismatch": "ダウンロードした {{browser}} {{version}} のアーカイブはチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
"nameCannotBeEmpty": "名前を空にすることはできません",
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。",
"profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します",
+2
View File
@@ -1889,6 +1889,8 @@
"camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.",
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
"browserChecksumUnavailable": "{{browser}} {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 다운로드가 중단되었으며 나중에 다시 시도됩니다.",
"browserChecksumMismatch": "다운로드한 {{browser}} {{version}} 아카이브가 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.",
"profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다",
+2
View File
@@ -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}}\"",
+2
View File
@@ -1903,6 +1903,8 @@
"camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.",
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
"browserChecksumUnavailable": "Не удалось проверить {{browser}} {{version}}: файл контрольных сумм не удалось получить. Загрузка остановлена; попытка будет повторена позже.",
"browserChecksumMismatch": "Загруженный архив {{browser}} {{version}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
"nameCannotBeEmpty": "Имя не может быть пустым",
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.",
"profileNameExists": "Профиль с именем «{{name}}» уже существует",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -1889,6 +1889,8 @@
"camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。",
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
"browserChecksumUnavailable": "无法验证 {{browser}} {{version}}:无法获取其校验和文件。下载已停止,稍后将重试。",
"browserChecksumMismatch": "下载的 {{browser}} {{version}} 压缩包未通过校验和验证,已被丢弃。请重试。",
"nameCannotBeEmpty": "名称不能为空",
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。",
"profileNameExists": "名为“{{name}}”的配置文件已存在",
+12
View File
@@ -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":