mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-16 01:17:23 +02:00
feat: sha256 checksum for self-updates
This commit is contained in:
@@ -280,6 +280,29 @@ jobs:
|
|||||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||||
rm -f $RUNNER_TEMP/build_certificate.p12 || 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:
|
changelog:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
needs: [release]
|
needs: [release]
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- 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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
security-events: write
|
security-events: write
|
||||||
@@ -210,7 +218,11 @@ jobs:
|
|||||||
id: timestamp
|
id: timestamp
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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)
|
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||||
@@ -289,7 +301,9 @@ jobs:
|
|||||||
- name: Generate nightly tag
|
- name: Generate nightly tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
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)
|
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -355,8 +369,16 @@ jobs:
|
|||||||
mkdir -p "$ASSETS_DIR"
|
mkdir -p "$ASSETS_DIR"
|
||||||
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
|
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"
|
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_*_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.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
|
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.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_*_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
|
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"
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
# Delete existing rolling nightly release and tag
|
# Delete existing rolling nightly release and tag
|
||||||
@@ -379,6 +405,7 @@ jobs:
|
|||||||
"$ASSETS_DIR"/Donut_nightly_* \
|
"$ASSETS_DIR"/Donut_nightly_* \
|
||||||
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
||||||
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
||||||
|
"$ASSETS_DIR"/SHA256SUMS.txt \
|
||||||
--title "Donut Browser Nightly" \
|
--title "Donut Browser Nightly" \
|
||||||
--notes-file /tmp/nightly-notes.md \
|
--notes-file /tmp/nightly-notes.md \
|
||||||
--prerelease
|
--prerelease
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ pub struct AppReleaseAsset {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub browser_download_url: String,
|
pub browser_download_url: String,
|
||||||
pub size: u64,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -111,6 +115,15 @@ pub struct AppUpdateInfo {
|
|||||||
pub release_page_url: Option<String>,
|
pub release_page_url: Option<String>,
|
||||||
/// True when a system package manager repo is configured (apt/dnf/zypper)
|
/// True when a system package manager repo is configured (apt/dnf/zypper)
|
||||||
pub repo_update: bool,
|
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 {
|
pub struct AppAutoUpdater {
|
||||||
@@ -214,6 +227,35 @@ impl AppAutoUpdater {
|
|||||||
// Find the appropriate asset for current platform
|
// Find the appropriate asset for current platform
|
||||||
let download_url = self.get_download_url_for_platform(&latest_release.assets);
|
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
|
// On Linux, when a package repo is configured, notify users to update via
|
||||||
// their package manager instead of auto-downloading from GitHub.
|
// their package manager instead of auto-downloading from GitHub.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -230,6 +272,8 @@ impl AppAutoUpdater {
|
|||||||
manual_update_required,
|
manual_update_required,
|
||||||
release_page_url: Some(release_page_url),
|
release_page_url: Some(release_page_url),
|
||||||
repo_update,
|
repo_update,
|
||||||
|
checksums_url,
|
||||||
|
asset_digest,
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -255,6 +299,8 @@ impl AppAutoUpdater {
|
|||||||
manual_update_required: false,
|
manual_update_required: false,
|
||||||
release_page_url: Some(release_page_url),
|
release_page_url: Some(release_page_url),
|
||||||
repo_update: false,
|
repo_update: false,
|
||||||
|
checksums_url,
|
||||||
|
asset_digest,
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -712,6 +758,156 @@ impl AppAutoUpdater {
|
|||||||
None
|
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)
|
/// Download the update file without progress tracking (silent download)
|
||||||
async fn download_update_silent(
|
async fn download_update_silent(
|
||||||
&self,
|
&self,
|
||||||
@@ -767,12 +963,24 @@ impl AppAutoUpdater {
|
|||||||
.unwrap_or("update.dmg")
|
.unwrap_or("update.dmg")
|
||||||
.to_string();
|
.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);
|
log::info!("Downloading update from: {}", update_info.download_url);
|
||||||
|
|
||||||
let download_path = self
|
let download_path = self
|
||||||
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
||||||
.await?;
|
.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...");
|
log::info!("Extracting update...");
|
||||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
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
|
updater
|
||||||
.download_and_prepare_update(&app_handle, &update_info)
|
.download_and_prepare_update(&app_handle, &update_info)
|
||||||
.await
|
.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]
|
#[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]
|
#[test]
|
||||||
fn test_platform_specific_download_urls() {
|
fn test_platform_specific_download_urls() {
|
||||||
let updater = AppAutoUpdater::instance();
|
let updater = AppAutoUpdater::instance();
|
||||||
@@ -1910,33 +2208,39 @@ mod tests {
|
|||||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
||||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
||||||
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
// Linux assets
|
// Linux assets
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
|
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
|
||||||
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
|
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2039,11 +2343,13 @@ mod tests {
|
|||||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2084,23 +2390,27 @@ mod tests {
|
|||||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
// Windows assets
|
// Windows assets
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
||||||
browser_download_url: "https://example.com/x64.msi".to_string(),
|
browser_download_url: "https://example.com/x64.msi".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
// Linux assets
|
// Linux assets
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
AppReleaseAsset {
|
AppReleaseAsset {
|
||||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||||
size: 12345,
|
size: 12345,
|
||||||
|
digest: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||||
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showToast } from "@/lib/toast-utils";
|
import { showToast } from "@/lib/toast-utils";
|
||||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||||
|
|
||||||
@@ -82,11 +83,16 @@ export function useAppUpdateNotifications() {
|
|||||||
showToast({
|
showToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: t("appUpdate.toast.updateFailed"),
|
title: t("appUpdate.toast.updateFailed"),
|
||||||
description: String(error),
|
description: translateBackendError(t, error),
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
setUpdateProgress(null);
|
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],
|
[t],
|
||||||
@@ -127,6 +133,12 @@ export function useAppUpdateNotifications() {
|
|||||||
"app-update-available",
|
"app-update-available",
|
||||||
(event) => {
|
(event) => {
|
||||||
console.log("App update available:", event.payload);
|
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);
|
setUpdateInfo(event.payload);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
"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.",
|
"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.",
|
"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": {
|
"rail": {
|
||||||
"profiles": "Profiles",
|
"profiles": "Profiles",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
"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.",
|
"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.",
|
"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": {
|
"rail": {
|
||||||
"profiles": "Perfiles",
|
"profiles": "Perfiles",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
"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éé.",
|
"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éé.",
|
"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": {
|
"rail": {
|
||||||
"profiles": "Profils",
|
"profiles": "Profils",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
||||||
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。"
|
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。",
|
||||||
|
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||||
|
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。"
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "プロファイル",
|
"profiles": "プロファイル",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||||
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요."
|
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요.",
|
||||||
|
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||||
|
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요."
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "프로필",
|
"profiles": "프로필",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
"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.",
|
"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.",
|
"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": {
|
"rail": {
|
||||||
"profiles": "Perfis",
|
"profiles": "Perfis",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
||||||
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern."
|
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern.",
|
||||||
|
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||||
|
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз."
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "Профили",
|
"profiles": "Профили",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.",
|
"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.",
|
"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.",
|
"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": {
|
"rail": {
|
||||||
"profiles": "Profile",
|
"profiles": "Profile",
|
||||||
|
|||||||
@@ -1798,7 +1798,9 @@
|
|||||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
||||||
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。"
|
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。",
|
||||||
|
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||||
|
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。"
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "配置文件",
|
"profiles": "配置文件",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export type BackendErrorCode =
|
|||||||
| "PROXY_PAYMENT_REQUIRED"
|
| "PROXY_PAYMENT_REQUIRED"
|
||||||
| "VPN_NOT_WORKING"
|
| "VPN_NOT_WORKING"
|
||||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||||
|
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||||
|
| "UPDATE_CHECKSUM_MISMATCH"
|
||||||
| "INTERNAL_ERROR";
|
| "INTERNAL_ERROR";
|
||||||
|
|
||||||
export interface BackendError {
|
export interface BackendError {
|
||||||
@@ -138,6 +140,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
|||||||
return t("backendErrors.vpnNotWorking");
|
return t("backendErrors.vpnNotWorking");
|
||||||
case "CAMOUFOX_IMPORT_DEPRECATED":
|
case "CAMOUFOX_IMPORT_DEPRECATED":
|
||||||
return t("backendErrors.camoufoxImportDeprecated");
|
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":
|
case "INTERNAL_ERROR":
|
||||||
return t("backendErrors.internal", {
|
return t("backendErrors.internal", {
|
||||||
detail: parsed.params?.detail ?? "",
|
detail: parsed.params?.detail ?? "",
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ export interface AppUpdateInfo {
|
|||||||
manual_update_required: boolean;
|
manual_update_required: boolean;
|
||||||
release_page_url?: string;
|
release_page_url?: string;
|
||||||
repo_update: boolean;
|
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 {
|
export interface AppUpdateProgress {
|
||||||
|
|||||||
Reference in New Issue
Block a user