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