Compare commits

..

17 Commits

Author SHA1 Message Date
zhom ed26786fdb chore: cargo fmt 2025-05-31 12:38:14 +04:00
zhom 966268ff05 chore: version bump 2025-05-31 12:37:29 +04:00
zhom 87ae696d7a refactor: make app_auto_updater use shared extraction logic 2025-05-31 12:31:16 +04:00
zhom 7e92b290b6 chore: update description and readme 2025-05-31 12:14:54 +04:00
zhom eb62e0abf9 chor: bump version 2025-05-31 11:39:23 +04:00
zhom 0ed5adf2ba chore: cargo fmt 2025-05-31 11:09:42 +04:00
zhom dd91aaeea0 chore: don't make regular release a draft 2025-05-31 11:08:50 +04:00
zhom 6a3407796d chore: version bump and build update 2025-05-31 11:07:59 +04:00
zhom eaa1a823db chore: cargo fmt 2025-05-30 07:48:32 +04:00
zhom 63900bd0ad chore: get build version at build time 2025-05-30 07:37:40 +04:00
zhom 31326c2d1f chore: disable ci for windows and linux for now 2025-05-30 07:26:26 +04:00
zhom 006a146770 feat: show confirmation dialog on profile deletion 2025-05-30 07:23:52 +04:00
zhom e3275248f7 chore: clippy 2025-05-30 07:13:20 +04:00
zhom 5c23c77896 feat: self-updates 2025-05-30 07:12:07 +04:00
zhom 03a3e9fc56 feat: mock all network requests in unit tests 2025-05-30 06:52:23 +04:00
zhom 26a5be55f1 chore: rename rolling release to nightly 2025-05-30 05:52:39 +04:00
zhom a58a814369 chore: block releases until lint passes 2025-05-30 05:27:52 +04:00
21 changed files with 2337 additions and 309 deletions
+2 -1
View File
@@ -3,6 +3,7 @@
name: Lint Node.js
on:
workflow_call:
push:
branches:
- main
@@ -18,7 +19,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [macos-latest]
runs-on: ${{ matrix.os }}
+2 -1
View File
@@ -3,6 +3,7 @@
name: Lint Rust
on:
workflow_call:
push:
branches:
- main
@@ -22,7 +23,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [macos-latest]
runs-on: ${{ matrix.os }}
+14 -1
View File
@@ -8,9 +8,21 @@ on:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
STABLE_RELEASE: "true"
jobs:
lint-js:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
release:
needs: [lint-js, lint-rust]
permissions:
contents: write
strategy:
@@ -114,10 +126,11 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Donut Browser ${{ github.ref_name }}"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
+17 -3
View File
@@ -10,7 +10,18 @@ env:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
jobs:
lint-js:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
rolling-release:
needs: [lint-js, lint-rust]
permissions:
contents: write
strategy:
@@ -83,10 +94,13 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
GITHUB_SHA: ${{ github.sha }}
with:
tagName: "alpha-${{ steps.commit.outputs.hash }}"
releaseName: "Donut Browser Alpha (Build ${{ steps.commit.outputs.hash }})"
releaseBody: "⚠️ **Alpha Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
tagName: "nightly-${{ steps.commit.outputs.hash }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
+2
View File
@@ -27,6 +27,8 @@
## Download
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it.
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
## Supported Platforms
+2 -2
View File
@@ -1,14 +1,14 @@
{
"name": "donutbrowser",
"private": true,
"version": "0.1.0",
"version": "0.2.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/ && tsc --noEmit && next lint",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky",
+95 -2
View File
@@ -82,6 +82,16 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -803,6 +813,24 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "deadpool"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
dependencies = [
"async-trait",
"deadpool-runtime",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "deflate64"
version = "0.1.9"
@@ -945,7 +973,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.1.0"
version = "0.2.2"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -966,6 +994,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-test",
"wiremock",
"zip",
]
@@ -1211,6 +1240,21 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1218,6 +1262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1285,6 +1330,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1653,6 +1699,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.4.0"
@@ -1728,6 +1780,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.6.0"
@@ -1741,6 +1799,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa 1.0.14",
"pin-project-lite",
"smallvec",
@@ -2471,6 +2530,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi 0.3.9",
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.3"
@@ -3095,7 +3164,7 @@ checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"hermit-abi 0.4.0",
"pin-project-lite",
"rustix 0.38.44",
"tracing",
@@ -5716,6 +5785,30 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "101681b74cd87b5899e87bcf5a64e83334dd313fcd3053ea72e6dba18928e301"
dependencies = [
"assert-json-diff",
"async-trait",
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
+4 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "donutbrowser"
version = "0.1.0"
description = "A Tauri App"
version = "0.2.2"
description = "Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
@@ -20,7 +20,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools"] }
tauri = { version = "2", features = ["devtools", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
@@ -41,6 +41,7 @@ core-foundation="0.10"
[dev-dependencies]
tempfile = "3.13.0"
tokio-test = "0.4.4"
wiremock = "0.6"
[features]
# by default Tauri runs in production mode
+1 -1
View File
@@ -13,7 +13,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.2.2</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
+21
View File
@@ -5,5 +5,26 @@ fn main() {
println!("cargo:rustc-link-lib=framework=CoreServices");
}
// Inject build version based on environment variables set by CI
if let Ok(build_tag) = std::env::var("BUILD_TAG") {
// Custom BUILD_TAG takes highest priority (used for nightly builds)
println!("cargo:rustc-env=BUILD_VERSION={build_tag}");
} else if let Ok(tag_name) = std::env::var("GITHUB_REF_NAME") {
// This is set by GitHub Actions to the tag name (e.g., "v1.0.0")
println!("cargo:rustc-env=BUILD_VERSION={tag_name}");
} else if std::env::var("STABLE_RELEASE").is_ok() {
// Fallback for stable releases - use CARGO_PKG_VERSION with 'v' prefix
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
// For nightly builds, use commit hash
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
} else {
// Development build fallback
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
tauri_build::build()
}
+549 -215
View File
@@ -231,12 +231,45 @@ struct CachedGithubData {
pub struct ApiClient {
client: Client,
firefox_api_base: String,
firefox_dev_api_base: String,
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
}
impl ApiClient {
pub fn new() -> Self {
Self {
client: Client::new(),
firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(),
firefox_dev_api_base: "https://product-details.mozilla.org/1.0".to_string(),
github_api_base: "https://api.github.com".to_string(),
chromium_api_base: "https://commondatastorage.googleapis.com/chromium-browser-snapshots"
.to_string(),
tor_archive_base: "https://archive.torproject.org/tor-package-archive/torbrowser".to_string(),
mozilla_download_base: "https://download.mozilla.org".to_string(),
}
}
#[cfg(test)]
pub fn new_with_base_urls(
firefox_api_base: String,
firefox_dev_api_base: String,
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
) -> Self {
Self {
client: Client::new(),
firefox_api_base,
firefox_dev_api_base,
github_api_base,
chromium_api_base,
tor_archive_base,
mozilla_download_base,
}
}
@@ -376,7 +409,8 @@ impl ApiClient {
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
download_url: Some(format!(
"https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
}
})
@@ -386,7 +420,7 @@ impl ApiClient {
}
println!("Fetching Firefox releases from Mozilla API...");
let url = "https://product-details.mozilla.org/1.0/firefox.json";
let url = format!("{}/firefox.json", self.firefox_api_base);
let response = self
.client
@@ -414,8 +448,8 @@ impl ApiClient {
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"https://download.mozilla.org/?product=firefox-{}&os=osx&lang=en-US",
release.version
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
@@ -460,7 +494,8 @@ impl ApiClient {
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
download_url: Some(format!(
"https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
}
})
@@ -470,7 +505,7 @@ impl ApiClient {
}
println!("Fetching Firefox Developer Edition releases from Mozilla API...");
let url = "https://product-details.mozilla.org/1.0/devedition.json";
let url = format!("{}/devedition.json", self.firefox_dev_api_base);
let response = self
.client
@@ -504,8 +539,8 @@ impl ApiClient {
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"https://download.mozilla.org/?product=devedition-{}&os=osx&lang=en-US",
release.version
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
@@ -534,6 +569,7 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_mullvad_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
@@ -552,7 +588,10 @@ impl ApiClient {
}
println!("Fetching Mullvad releases from GitHub API...");
let url = "https://api.github.com/repos/mullvad/mullvad-browser/releases";
let url = format!(
"{}/repos/mullvad/mullvad-browser/releases",
self.github_api_base
);
let releases = self
.client
.get(url)
@@ -583,6 +622,7 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_zen_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
@@ -601,7 +641,10 @@ impl ApiClient {
}
println!("Fetching Zen releases from GitHub API...");
let url = "https://api.github.com/repos/zen-browser/desktop/releases";
let url = format!(
"{}/repos/zen-browser/desktop/releases",
self.github_api_base
);
let mut releases = self
.client
.get(url)
@@ -624,6 +667,7 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_brave_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
@@ -642,7 +686,10 @@ impl ApiClient {
}
println!("Fetching Brave releases from GitHub API...");
let url = "https://api.github.com/repos/brave/brave-browser/releases";
let url = format!(
"{}/repos/brave/brave-browser/releases",
self.github_api_base
);
let releases = self
.client
.get(url)
@@ -695,9 +742,7 @@ impl ApiClient {
} else {
"Mac"
};
let url = format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/LAST_CHANGE"
);
let url = format!("{}/{arch}/LAST_CHANGE", self.chromium_api_base);
let version = self
.client
.get(&url)
@@ -777,21 +822,27 @@ impl ApiClient {
// Check cache first (unless bypassing)
if !no_caching {
if let Some(cached_versions) = self.load_cached_versions("tor-browser") {
return Ok(cached_versions.into_iter().map(|version| {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Assume all archived versions are stable
download_url: Some(format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
)),
}
}).collect());
return Ok(
cached_versions
.into_iter()
.map(|version| {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Assume all archived versions are stable
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
)),
}
})
.collect(),
);
}
}
println!("Fetching TOR releases from archive...");
let url = "https://archive.torproject.org/tor-package-archive/torbrowser/";
let url = format!("{}/", self.tor_archive_base);
let html = self
.client
.get(url)
@@ -849,23 +900,29 @@ impl ApiClient {
}
}
Ok(version_strings.into_iter().map(|version| {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // TOR archive doesn't provide structured dates
is_prerelease: false, // Assume all archived versions are stable
download_url: Some(format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
)),
}
}).collect())
Ok(
version_strings
.into_iter()
.map(|version| {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // TOR archive doesn't provide structured dates
is_prerelease: false, // Assume all archived versions are stable
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
)),
}
})
.collect(),
)
}
async fn check_tor_version_has_macos(
&self,
version: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("https://archive.torproject.org/tor-package-archive/torbrowser/{version}/");
let url = format!("{}/{version}/", self.tor_archive_base);
let html = self
.client
.get(&url)
@@ -883,6 +940,24 @@ impl ApiClient {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
MockServer::start().await
}
fn create_test_client(server: &MockServer) -> ApiClient {
let base_url = server.uri();
ApiClient::new_with_base_urls(
base_url.clone(), // firefox_api_base
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
#[test]
fn test_version_parsing() {
@@ -981,236 +1056,495 @@ mod tests {
#[tokio::test]
async fn test_firefox_api() {
let client = ApiClient::new();
let result = client.fetch_firefox_releases_with_caching(false).await;
let server = setup_mock_server().await;
let client = create_test_client(&server);
match result {
Ok(releases) => {
assert!(!releases.is_empty(), "Should have Firefox releases");
// Check that releases have required fields
let first_release = &releases[0];
assert!(
!first_release.version.is_empty(),
"Version should not be empty"
);
assert!(
first_release.download_url.is_some(),
"Should have download URL"
);
println!("Firefox API test passed. Found {} releases", releases.len());
println!("Latest version: {}", releases[0].version);
let mock_response = r#"{
"releases": {
"firefox-139.0": {
"build_number": 1,
"category": "major",
"date": "2024-01-15",
"description": "Firefox 139.0 Release",
"is_security_driven": false,
"product": "firefox",
"version": "139.0"
},
"firefox-138.0": {
"build_number": 1,
"category": "major",
"date": "2024-01-01",
"description": "Firefox 138.0 Release",
"is_security_driven": false,
"product": "firefox",
"version": "138.0"
}
}
Err(e) => {
println!("Firefox API test failed: {e}");
panic!("Firefox API should work");
}
}
}"#;
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_firefox_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "139.0");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
async fn test_firefox_developer_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let client = ApiClient::new();
let result = client
.fetch_firefox_developer_releases_with_caching(false)
let mock_response = r#"{
"releases": {
"devedition-140.0b1": {
"build_number": 1,
"category": "major",
"date": "2024-01-20",
"description": "Firefox Developer Edition 140.0b1",
"is_security_driven": false,
"product": "devedition",
"version": "140.0b1"
}
}
}"#;
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
match result {
Ok(releases) => {
assert!(
!releases.is_empty(),
"Should have Firefox Developer releases"
);
let result = client
.fetch_firefox_developer_releases_with_caching(true)
.await;
let first_release = &releases[0];
assert!(
!first_release.version.is_empty(),
"Version should not be empty"
);
assert!(
first_release.download_url.is_some(),
"Should have download URL"
);
println!(
"Firefox Developer API test passed. Found {} releases",
releases.len()
);
println!("Latest version: {}", releases[0].version);
}
Err(e) => {
println!("Firefox Developer API test failed: {e}");
panic!("Firefox Developer API should work");
}
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "140.0b1");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
async fn test_mullvad_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let client = ApiClient::new();
let result = client.fetch_mullvad_releases().await;
match result {
Ok(releases) => {
assert!(!releases.is_empty(), "Should have Mullvad releases");
let first_release = &releases[0];
assert!(
!first_release.tag_name.is_empty(),
"Tag name should not be empty"
);
println!("Mullvad API test passed. Found {} releases", releases.len());
println!("Latest version: {}", releases[0].tag_name);
let mock_response = r#"[
{
"tag_name": "14.5a6",
"name": "Mullvad Browser 14.5a6",
"prerelease": true,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
}
]
}
Err(e) => {
println!("Mullvad API test failed: {e}");
panic!("Mullvad API should work");
}
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_mullvad_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_alpha);
}
#[tokio::test]
async fn test_zen_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let client = ApiClient::new();
let result = client.fetch_zen_releases().await;
match result {
Ok(releases) => {
assert!(!releases.is_empty(), "Should have Zen releases");
let first_release = &releases[0];
assert!(
!first_release.tag_name.is_empty(),
"Tag name should not be empty"
);
println!("Zen API test passed. Found {} releases", releases.len());
println!("Latest version: {}", releases[0].tag_name);
let mock_response = r#"[
{
"tag_name": "1.0.0-twilight",
"name": "Zen Browser Twilight",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-twilight.dmg"
}
]
}
Err(e) => {
println!("Zen API test failed: {e}");
panic!("Zen API should work");
}
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_zen_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "1.0.0-twilight");
}
#[tokio::test]
async fn test_brave_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let client = ApiClient::new();
let result = client.fetch_brave_releases().await;
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
}
]
}
]"#;
match result {
Ok(releases) => {
// Note: Brave might not always have macOS releases, so we don't assert non-empty
println!(
"Brave API test passed. Found {} releases with macOS assets",
releases.len()
);
if !releases.is_empty() {
println!("Latest version: {}", releases[0].tag_name);
}
}
Err(e) => {
println!("Brave API test failed: {e}");
panic!("Brave API should work");
}
}
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_brave_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(!releases[0].is_alpha);
}
#[tokio::test]
async fn test_chromium_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let arch = if cfg!(target_arch = "aarch64") {
"Mac_Arm"
} else {
"Mac"
};
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
.insert_header("content-type", "text/plain"),
)
.mount(&server)
.await;
let client = ApiClient::new();
let result = client.fetch_chromium_latest_version().await;
match result {
Ok(version) => {
assert!(!version.is_empty(), "Version should not be empty");
assert!(
version.chars().all(|c| c.is_ascii_digit()),
"Version should be numeric"
);
assert!(result.is_ok());
let version = result.unwrap();
assert_eq!(version, "1465660");
}
println!("Chromium API test passed. Latest version: {version}");
}
Err(e) => {
println!("Chromium API test failed: {e}");
panic!("Chromium API should work");
}
}
#[tokio::test]
async fn test_chromium_releases_with_caching() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let arch = if cfg!(target_arch = "aarch64") {
"Mac_Arm"
} else {
"Mac"
};
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
.insert_header("content-type", "text/plain"),
)
.mount(&server)
.await;
let result = client.fetch_chromium_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "1465660");
assert!(!releases[0].is_prerelease);
}
#[tokio::test]
async fn test_tor_api() {
tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let client = ApiClient::new();
let mock_html = r#"
<html>
<body>
<a href="../">../</a>
<a href="14.0.4/">14.0.4/</a>
<a href="14.0.3/">14.0.3/</a>
</body>
</html>
"#;
// Use a timeout for this test since TOR API can be slow
let timeout_duration = tokio::time::Duration::from_secs(30);
let result = tokio::time::timeout(
timeout_duration,
client.fetch_tor_releases_with_caching(false),
)
.await;
let version_html = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
</body>
</html>
"#;
match result {
Ok(Ok(releases)) => {
assert!(!releases.is_empty(), "Should have TOR releases");
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let first_release = &releases[0];
assert!(
!first_release.version.is_empty(),
"Version should not be empty"
);
assert!(
first_release.download_url.is_some(),
"Should have download URL"
);
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
println!("TOR API test passed. Found {} releases", releases.len());
println!("Latest version: {}", releases[0].version);
}
Ok(Err(e)) => {
println!("TOR API test failed: {e}");
// Don't panic for TOR API since it can be unreliable
println!("TOR API test skipped due to network issues");
}
Err(_) => {
println!("TOR API test timed out after 30 seconds");
// Don't panic for timeout, just skip
println!("TOR API test skipped due to timeout");
}
}
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3"))
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let result = client.fetch_tor_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "14.0.4");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
async fn test_tor_version_check() {
tokio::time::sleep(tokio::time::Duration::from_millis(3500)).await; // Rate limiting
let server = setup_mock_server().await;
let client = create_test_client(&server);
let version_html = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let client = ApiClient::new();
let result = client.check_tor_version_has_macos("14.0.4").await;
match result {
Ok(has_macos) => {
assert!(has_macos, "Version 14.0.4 should have macOS support");
println!("TOR version check test passed. Version 14.0.4 has macOS: {has_macos}");
}
Err(e) => {
println!("TOR version check test failed: {e}");
panic!("TOR version check should work");
}
}
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn test_tor_version_check_no_macos() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let version_html = r#"
<html>
<body>
<a href="tor-browser-linux-14.0.4.tar.xz">tor-browser-linux-14.0.4.tar.xz</a>
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/14.0.5/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let result = client.check_tor_version_has_macos("14.0.5").await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_is_alpha_version() {
assert!(is_alpha_version("1.2.3a1"));
assert!(is_alpha_version("137.0b5"));
assert!(is_alpha_version("140.0rc1"));
assert!(!is_alpha_version("139.0"));
assert!(!is_alpha_version("1.2.3"));
}
#[test]
fn test_sort_versions_comprehensive() {
let mut versions = vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.1.0".to_string(),
"2.0.0a1".to_string(),
"2.0.0b1".to_string(),
"2.0.0rc1".to_string(),
"2.0.0".to_string(),
"10.0.0".to_string(),
"1.0.0-twilight".to_string(),
];
sort_versions(&mut versions);
// Twilight should be first, then normal semantic versioning
assert_eq!(versions[0], "1.0.0-twilight");
assert_eq!(versions[1], "10.0.0");
assert_eq!(versions[2], "2.0.0");
assert_eq!(versions[3], "2.0.0rc1");
assert_eq!(versions[4], "2.0.0b1");
assert_eq!(versions[5], "2.0.0a1");
}
#[tokio::test]
async fn test_error_handling_404() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let result = client.fetch_firefox_releases_with_caching(true).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_error_handling_invalid_json() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("invalid json")
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_firefox_releases_with_caching(true).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_github_api_rate_limit() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
.mount(&server)
.await;
let result = client.fetch_zen_releases_with_caching(true).await;
assert!(result.is_err());
}
}
+681
View File
@@ -0,0 +1,681 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::Emitter;
use crate::extraction::Extractor;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppReleaseAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppRelease {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub prerelease: bool,
pub assets: Vec<AppReleaseAsset>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppUpdateInfo {
pub current_version: String,
pub new_version: String,
pub release_notes: String,
pub download_url: String,
pub is_nightly: bool,
pub published_at: String,
}
pub struct AppAutoUpdater {
client: Client,
}
impl AppAutoUpdater {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
/// Check if running a nightly build based on environment variable
pub fn is_nightly_build() -> bool {
// If STABLE_RELEASE env var is set at compile time, it's a stable build
if option_env!("STABLE_RELEASE").is_some() {
return false;
}
// Also check if the current version starts with "nightly-"
let current_version = Self::get_current_version();
if current_version.starts_with("nightly-") {
return true;
}
// If STABLE_RELEASE is not set and version doesn't start with "nightly-",
// it's still considered a nightly build (dev builds, main branch builds, etc.)
true
}
/// Get current app version from build-time injection
pub fn get_current_version() -> String {
// Use build-time injected version instead of CARGO_PKG_VERSION
env!("BUILD_VERSION").to_string()
}
/// Check for app updates
pub async fn check_for_updates(
&self,
) -> Result<Option<AppUpdateInfo>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = Self::get_current_version();
let is_nightly = Self::is_nightly_build();
println!("=== App Update Check ===");
println!("Current version: {current_version}");
println!("Is nightly build: {is_nightly}");
println!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));
let releases = self.fetch_app_releases().await?;
println!("Fetched {} releases from GitHub", releases.len());
// Filter releases based on build type
let filtered_releases: Vec<&AppRelease> = if is_nightly {
// For nightly builds, look for nightly releases
let nightly_releases: Vec<&AppRelease> = releases
.iter()
.filter(|release| release.tag_name.starts_with("nightly-"))
.collect();
println!("Found {} nightly releases", nightly_releases.len());
nightly_releases
} else {
// For stable builds, look for stable releases (semver format)
let stable_releases: Vec<&AppRelease> = releases
.iter()
.filter(|release| {
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
})
.collect();
println!("Found {} stable releases", stable_releases.len());
stable_releases
};
if filtered_releases.is_empty() {
println!("No releases found for build type (nightly: {is_nightly})");
return Ok(None);
}
// Get the latest release
let latest_release = filtered_releases[0];
println!(
"Latest release: {} ({})",
latest_release.tag_name, latest_release.name
);
// Check if we need to update
if self.should_update(&current_version, &latest_release.tag_name, is_nightly) {
println!("Update available!");
// Find the appropriate asset for current platform
if let Some(download_url) = self.get_download_url_for_platform(&latest_release.assets) {
let update_info = AppUpdateInfo {
current_version,
new_version: latest_release.tag_name.clone(),
release_notes: latest_release.body.clone(),
download_url,
is_nightly,
published_at: latest_release.published_at.clone(),
};
println!(
"Update info prepared: {} -> {}",
update_info.current_version, update_info.new_version
);
return Ok(Some(update_info));
} else {
println!("No suitable download asset found for current platform");
}
} else {
println!("No update needed");
}
Ok(None)
}
/// Fetch app releases from GitHub
async fn fetch_app_releases(
&self,
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.send()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API request failed: {}", response.status()).into());
}
let releases: Vec<AppRelease> = response.json().await?;
Ok(releases)
}
/// Determine if an update should be performed
fn should_update(&self, current_version: &str, new_version: &str, is_nightly: bool) -> bool {
println!(
"Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}"
);
if is_nightly {
// For nightly builds, always update if there's a newer nightly
if let (Some(current_hash), Some(new_hash)) = (
current_version.strip_prefix("nightly-"),
new_version.strip_prefix("nightly-"),
) {
// Different commit hashes mean we should update
let should_update = new_hash != current_hash;
println!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
return should_update;
}
// If current version doesn't have nightly prefix but we're in nightly mode,
// this could be a dev build or stable build upgrading to nightly
if !current_version.starts_with("nightly-") {
println!("Upgrading from non-nightly to nightly: {new_version}");
return true;
}
} else {
// For stable builds, use semantic versioning comparison
let should_update = self.is_version_newer(new_version, current_version);
println!("Stable comparison: {new_version} > {current_version} = {should_update}");
return should_update;
}
false
}
/// Compare semantic versions (returns true if version1 > version2)
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
let v1 = self.parse_semver(version1);
let v2 = self.parse_semver(version2);
v1 > v2
}
/// Parse semantic version string into comparable tuple
fn parse_semver(&self, version: &str) -> (u32, u32, u32) {
let clean_version = version.trim_start_matches('v');
let parts: Vec<&str> = clean_version.split('.').collect();
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
(major, minor, patch)
}
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
"x64"
} else {
"unknown"
};
println!("Looking for assets with architecture: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
// Priority 1: Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg"))
|| asset.name.contains(&format!("-{arch}.dmg"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found exact architecture match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Priority 2: Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
{
println!("Found x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
// Priority 3: Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
{
println!("Found arm64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
// Priority 4: Fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
|| asset.name.to_lowercase().contains("darwin")
|| !asset.name.contains(".app.tar.gz"))
{
// Exclude app.tar.gz files
println!("Found fallback DMG: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
println!("No suitable asset found for platform");
None
}
/// Download and install app update
pub async fn download_and_install_update(
&self,
app_handle: &tauri::AppHandle,
update_info: &AppUpdateInfo,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create temporary directory for download
let temp_dir = std::env::temp_dir().join("donut_app_update");
fs::create_dir_all(&temp_dir)?;
// Extract filename from URL
let filename = update_info
.download_url
.split('/')
.next_back()
.unwrap_or("update.dmg")
.to_string();
// Emit download start event
let _ = app_handle.emit("app-update-progress", "Downloading update...");
// Download the update
let download_path = self
.download_update(&update_info.download_url, &temp_dir, &filename)
.await?;
// Emit extraction start event
let _ = app_handle.emit("app-update-progress", "Preparing update...");
// Extract the update
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
// Emit installation start event
let _ = app_handle.emit("app-update-progress", "Installing update...");
// Install the update (overwrite current app)
self.install_update(&extracted_app_path).await?;
// Clean up temporary files
let _ = fs::remove_dir_all(&temp_dir);
// Emit completion event
let _ = app_handle.emit("app-update-progress", "Update completed. Restarting...");
// Restart the application
self.restart_application().await?;
Ok(())
}
/// Download the update file
async fn download_update(
&self,
download_url: &str,
dest_dir: &Path,
filename: &str,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_dir.join(filename);
let response = self
.client
.get(download_url)
.header("User-Agent", "donutbrowser")
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let mut file = fs::File::create(&file_path)?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk)?;
}
Ok(file_path)
}
/// Extract the update using the extraction module
async fn extract_update(
&self,
archive_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let extractor = Extractor::new();
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension {
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
}
/// Install the update by replacing the current app
async fn install_update(
&self,
new_app_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get the current application bundle path
let current_app_path = self.get_current_app_path()?;
// Create a backup of the current app
let backup_path = current_app_path.with_extension("app.backup");
if backup_path.exists() {
fs::remove_dir_all(&backup_path)?;
}
// Move current app to backup
fs::rename(&current_app_path, &backup_path)?;
// Move new app to current location
fs::rename(new_app_path, &current_app_path)?;
// Remove quarantine attributes from the new app
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
let _ = Command::new("xattr")
.args(["-cr", current_app_path.to_str().unwrap()])
.output();
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
Ok(())
}
/// Get the current application bundle path
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Get the current executable path
let exe_path = std::env::current_exe()?;
// Navigate up to find the .app bundle
let mut current = exe_path.as_path();
while let Some(parent) = current.parent() {
if parent.extension().is_some_and(|ext| ext == "app") {
return Ok(parent.to_path_buf());
}
current = parent;
}
Err("Could not find application bundle".into())
}
/// Restart the application
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
// Create a temporary restart script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.sh");
// Create the restart script content
let script_content = format!(
r#"#!/bin/bash
# Wait for the current process to exit
while kill -0 {} 2>/dev/null; do
sleep 0.5
done
# Wait a bit more to ensure clean exit
sleep 1
# Start the new application
open "{}"
# Clean up this script
rm "{}"
"#,
current_pid,
app_path.to_str().unwrap(),
script_path.to_str().unwrap()
);
// Write the script to file
fs::write(&script_path, script_content)?;
// Make the script executable
let _ = Command::new("chmod")
.args(["+x", script_path.to_str().unwrap()])
.output();
// Execute the restart script in the background
let mut cmd = Command::new("bash");
cmd.arg(script_path.to_str().unwrap());
// Detach the process completely
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
}
// Tauri commands
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
let updater = AppAutoUpdater::new();
updater
.check_for_updates()
.await
.map_err(|e| format!("Failed to check for app updates: {e}"))
}
#[tauri::command]
pub async fn download_and_install_app_update(
app_handle: tauri::AppHandle,
update_info: AppUpdateInfo,
) -> Result<(), String> {
let updater = AppAutoUpdater::new();
updater
.download_and_install_update(&app_handle, &update_info)
.await
.map_err(|e| format!("Failed to install app update: {e}"))
}
#[tauri::command]
pub fn get_app_version_info() -> Result<(String, bool), String> {
Ok((
AppAutoUpdater::get_current_version(),
AppAutoUpdater::is_nightly_build(),
))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
let updater = AppAutoUpdater::new();
updater
.check_for_updates()
.await
.map_err(|e| format!("Failed to check for app updates: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_nightly_build() {
// This will depend on whether STABLE_RELEASE is set during test compilation
let is_nightly = AppAutoUpdater::is_nightly_build();
println!("Is nightly build: {is_nightly}");
// The result should be true for test builds since STABLE_RELEASE is not set
// unless the test is run in a stable release environment
assert!(is_nightly || option_env!("STABLE_RELEASE").is_some());
}
#[test]
fn test_version_comparison() {
let updater = AppAutoUpdater::new();
// Test semantic version comparison
assert!(updater.is_version_newer("v1.1.0", "v1.0.0"));
assert!(updater.is_version_newer("v2.0.0", "v1.9.9"));
assert!(updater.is_version_newer("v1.0.1", "v1.0.0"));
assert!(!updater.is_version_newer("v1.0.0", "v1.0.0"));
assert!(!updater.is_version_newer("v1.0.0", "v1.0.1"));
}
#[test]
fn test_parse_semver() {
let updater = AppAutoUpdater::new();
assert_eq!(updater.parse_semver("v1.2.3"), (1, 2, 3));
assert_eq!(updater.parse_semver("1.2.3"), (1, 2, 3));
assert_eq!(updater.parse_semver("v2.0.0"), (2, 0, 0));
assert_eq!(updater.parse_semver("0.1.0"), (0, 1, 0));
}
#[test]
fn test_should_update_stable() {
let updater = AppAutoUpdater::new();
// Stable version updates
assert!(updater.should_update("v1.0.0", "v1.1.0", false));
assert!(updater.should_update("v1.0.0", "v2.0.0", false));
assert!(!updater.should_update("v1.1.0", "v1.0.0", false));
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
}
#[test]
fn test_should_update_nightly() {
let updater = AppAutoUpdater::new();
// Nightly version updates
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
// Upgrade from stable to nightly
assert!(updater.should_update("v1.0.0", "nightly-abc123", true));
// Upgrade from dev to nightly
assert!(updater.should_update("dev-0.1.0", "nightly-abc123", true));
}
#[test]
fn test_should_update_edge_cases() {
let updater = AppAutoUpdater::new();
// Test with different nightly formats
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
// Test stable version edge cases
assert!(updater.should_update("v0.9.9", "v1.0.0", false));
assert!(!updater.should_update("v1.0.0", "v0.9.9", false));
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
// Test version without 'v' prefix
assert!(updater.should_update("0.9.9", "v1.0.0", false));
assert!(updater.should_update("v0.9.9", "1.0.0", false));
}
#[test]
fn test_get_download_url_for_platform() {
let updater = AppAutoUpdater::new();
let assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// The exact URL depends on the target architecture
let url = url.unwrap();
assert!(url.contains(".dmg"));
}
#[test]
fn test_extract_update_uses_extractor() {
// This test verifies that the extract_update method properly uses the Extractor
// We can't run the actual extraction in unit tests without real DMG files,
// but we can verify the method signature and basic logic
let updater = AppAutoUpdater::new();
// Test that unsupported formats would be rejected
let temp_dir = std::env::temp_dir();
let unsupported_file = temp_dir.join("test.rar");
// Create a mock runtime to test the logic
let rt = tokio::runtime::Runtime::new().unwrap();
// This would fail because .rar is not supported, which proves
// our method is using the Extractor logic
let result = rt.block_on(async { updater.extract_update(&unsupported_file, &temp_dir).await });
// Should fail with unsupported format error
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Unsupported archive format: rar"));
}
}
+502 -69
View File
@@ -34,6 +34,14 @@ impl Downloader {
}
}
#[cfg(test)]
pub fn new_with_api_client(api_client: ApiClient) -> Self {
Self {
client: Client::new(),
api_client,
}
}
/// Resolve the actual download URL for browsers that need dynamic asset resolution
pub async fn resolve_download_url(
&self,
@@ -44,7 +52,10 @@ impl Downloader {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
let releases = self.api_client.fetch_brave_releases().await?;
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
.await?;
// Find the release with the matching version
let release = releases
@@ -67,7 +78,10 @@ impl Downloader {
}
BrowserType::Zen => {
// For Zen, verify the asset exists
let releases = self.api_client.fetch_zen_releases().await?;
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
.await?;
let release = releases
.iter()
@@ -87,7 +101,10 @@ impl Downloader {
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
let releases = self.api_client.fetch_mullvad_releases().await?;
let releases = self
.api_client
.fetch_mullvad_releases_with_caching(true)
.await?;
let release = releases
.iter()
@@ -112,9 +129,9 @@ impl Downloader {
}
}
pub async fn download_browser(
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle<R>,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
@@ -149,6 +166,11 @@ impl Downloader {
.send()
.await?;
// Check if the response is successful
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let total_size = response.content_length();
let mut downloaded = 0u64;
let start_time = std::time::Instant::now();
@@ -206,12 +228,62 @@ impl Downloader {
#[cfg(test)]
mod tests {
use super::*;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
MockServer::start().await
}
fn create_test_api_client(server: &MockServer) -> ApiClient {
let base_url = server.uri();
ApiClient::new_with_base_urls(
base_url.clone(), // firefox_api_base
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
#[tokio::test]
async fn test_resolve_brave_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
// Test with a known Brave version
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "brave-test.dmg".to_string(),
@@ -222,23 +294,42 @@ mod tests {
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
.await;
match result {
Ok(url) => {
assert!(url.contains("github.com/brave/brave-browser"));
assert!(url.contains(".dmg"));
assert!(url.contains("universal"));
println!("Brave download URL resolved: {url}");
}
Err(e) => {
println!("Brave URL resolution failed (expected if version doesn't exist): {e}");
// This might fail if the version doesn't exist, which is okay for testing
}
}
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
}
#[tokio::test]
async fn test_resolve_zen_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "1.11b",
"name": "Zen Browser 1.11b",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg"
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
@@ -250,21 +341,42 @@ mod tests {
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
.await;
match result {
Ok(url) => {
assert!(url.contains("github.com/zen-browser/desktop"));
assert!(url.contains("zen.macos-universal.dmg"));
println!("Zen download URL resolved: {url}");
}
Err(e) => {
println!("Zen URL resolution failed (expected if version doesn't exist): {e}");
}
}
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, "https://example.com/zen-1.11b-universal.dmg");
}
#[tokio::test]
async fn test_resolve_mullvad_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "14.5a6",
"name": "Mullvad Browser 14.5a6",
"prerelease": true,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
@@ -276,21 +388,16 @@ mod tests {
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
.await;
match result {
Ok(url) => {
assert!(url.contains("github.com/mullvad/mullvad-browser"));
assert!(url.contains(".dmg"));
println!("Mullvad download URL resolved: {url}");
}
Err(e) => {
println!("Mullvad URL resolution failed (expected if version doesn't exist): {e}");
}
}
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, "https://example.com/mullvad-14.5a6.dmg");
}
#[tokio::test]
async fn test_resolve_firefox_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
@@ -302,20 +409,16 @@ mod tests {
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
.await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("Firefox download URL (passthrough): {url}");
}
Err(e) => {
panic!("Firefox URL resolution should not fail: {e}");
}
}
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_chromium_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
@@ -327,20 +430,16 @@ mod tests {
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
.await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("Chromium download URL (passthrough): {url}");
}
Err(e) => {
panic!("Chromium URL resolution should not fail: {e}");
}
}
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_tor_download_url() {
let downloader = Downloader::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://archive.torproject.org/tor-package-archive/torbrowser/14.0.4/tor-browser-macos-14.0.4.dmg".to_string(),
@@ -352,14 +451,348 @@ mod tests {
.resolve_download_url(BrowserType::TorBrowser, "14.0.4", &download_info)
.await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("TOR download URL (passthrough): {url}");
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_brave_version_not_found() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "v1.81.8",
"name": "Brave Release 1.81.8",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.8-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg"
}
]
}
Err(e) => {
panic!("TOR URL resolution should not fail: {e}");
]"#;
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "brave-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Brave version v1.81.9 not found"));
}
#[tokio::test]
async fn test_resolve_zen_asset_not_found() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "1.11b",
"name": "Zen Browser 1.11b",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "zen.linux-universal.tar.bz2",
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2"
}
]
}
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "zen-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS universal asset found"));
}
#[tokio::test]
async fn test_download_browser_with_progress() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
// Create a temporary directory for the test
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create test file content (simulating a small download)
let test_content = b"This is a test file content for download simulation";
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content)
.insert_header("content-length", test_content.len().to_string())
.insert_header("content-type", "application/octet-stream"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/test-download", server.uri()),
filename: "test-file.dmg".to_string(),
is_archive: true,
};
// Create a mock app handle for testing
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
)
.await;
assert!(result.is_ok());
let downloaded_file = result.unwrap();
assert!(downloaded_file.exists());
// Verify file content
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content, test_content);
}
#[tokio::test]
async fn test_download_browser_network_error() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/missing-file", server.uri()),
filename: "missing-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_resolve_mullvad_asset_not_found() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "14.5a6",
"name": "Mullvad Browser 14.5a6",
"prerelease": true,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "mullvad-browser-linux-14.5a6.tar.xz",
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz"
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "mullvad-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS asset found"));
}
#[tokio::test]
async fn test_brave_version_with_v_prefix() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "brave-test.dmg".to_string(),
is_archive: true,
};
// Test with version without v prefix
let result = downloader
.resolve_download_url(BrowserType::Brave, "1.81.9", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
}
#[tokio::test]
async fn test_download_browser_chunked_response() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create larger test content to simulate chunked transfer
let test_content = vec![42u8; 1024]; // 1KB of data
Mock::given(method("GET"))
.and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content.clone())
.insert_header("content-length", test_content.len().to_string())
.insert_header("content-type", "application/octet-stream"),
)
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/chunked-download", server.uri()),
filename: "chunked-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let result = downloader
.download_browser(
&app_handle,
BrowserType::Chromium,
"1465660",
&download_info,
dest_path,
)
.await;
assert!(result.is_ok());
let downloaded_file = result.unwrap();
assert!(downloaded_file.exists());
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ impl Extractor {
}
}
async fn extract_dmg(
pub async fn extract_dmg(
&self,
dmg_path: &Path,
dest_dir: &Path,
@@ -149,7 +149,7 @@ impl Extractor {
Ok(app_path)
}
async fn extract_zip(
pub async fn extract_zip(
&self,
zip_path: &Path,
dest_dir: &Path,
+41 -5
View File
@@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
mod api_client;
mod app_auto_updater;
mod auto_updater;
mod browser;
mod browser_runner;
@@ -52,6 +53,11 @@ use auto_updater::{
mark_auto_update_download, remove_auto_update_download, start_browser_update,
};
use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
#[tauri::command]
fn greet() -> String {
let now = SystemTime::now();
@@ -172,6 +178,36 @@ pub fn run() {
updater_guard.start_background_updates().await;
});
// Check for app updates at startup
let app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Add a small delay to ensure the app is fully loaded
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
println!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::new();
match updater.check_for_updates().await {
Ok(Some(update_info)) => {
println!(
"App update available: {} -> {}",
update_info.current_version, update_info.new_version
);
// Emit update available event to the frontend
if let Err(e) = app_handle_update.emit("app-update-available", &update_info) {
eprintln!("Failed to emit app update event: {e}");
} else {
println!("App update event emitted successfully");
}
}
Ok(None) => {
println!("No app updates available");
}
Err(e) => {
eprintln!("Failed to check for app updates: {e}");
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -182,7 +218,7 @@ pub fn run() {
is_browser_downloaded,
check_browser_exists,
create_browser_profile_new,
create_browser_profile, // Keep for backward compatibility
create_browser_profile,
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
@@ -199,26 +235,22 @@ pub fn run() {
check_browser_status,
kill_browser_profile,
rename_profile,
// Settings commands
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
// Default browser commands
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
// Version update commands
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
// Auto-update commands
check_for_browser_updates,
start_browser_update,
complete_browser_update,
@@ -228,6 +260,10 @@ pub fn run() {
mark_auto_update_download,
remove_auto_update_download,
is_auto_update_download,
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
get_app_version_info,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.1.0",
"version": "0.2.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+8 -1
View File
@@ -13,6 +13,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
@@ -53,6 +54,10 @@ export default function Home() {
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// App auto-update functionality
const appUpdateNotifications = useAppUpdateNotifications();
const { checkForAppUpdatesManual } = appUpdateNotifications;
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
@@ -249,7 +254,9 @@ export default function Home() {
await loadProfiles();
} catch (error) {
setError(
`Failed to create profile: ${error instanceof Error ? error.message : String(error)}`,
`Failed to create profile: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw error;
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import React from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { LuRefreshCw } from "react-icons/lu";
interface AppUpdateInfo {
current_version: string;
new_version: string;
release_notes: string;
download_url: string;
is_nightly: boolean;
published_at: string;
}
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
onDismiss: () => void;
isUpdating?: boolean;
updateProgress?: string;
}
export function AppUpdateToast({
updateInfo,
onUpdate,
onDismiss,
isUpdating = false,
updateProgress,
}: AppUpdateToastProps) {
const handleUpdateClick = async () => {
await onUpdate(updateInfo);
};
return (
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-lg max-w-md">
<div className="mr-3 mt-0.5">
{isUpdating ? (
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
) : (
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm">
Donut Browser Update Available
</span>
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
className="text-xs"
>
{updateInfo.is_nightly ? "Nightly" : "Stable"}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
</div>
</div>
{!isUpdating && (
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="h-6 w-6 p-0 shrink-0"
>
<FaTimes className="h-3 w-3" />
</Button>
)}
</div>
{isUpdating && updateProgress && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">{updateProgress}</p>
</div>
)}
{!isUpdating && (
<div className="flex items-center gap-2 mt-3">
<Button
onClick={() => void handleUpdateClick()}
size="sm"
className="flex items-center gap-2 text-xs"
>
<FaDownload className="h-3 w-3" />
Update Now
</Button>
<Button
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</Button>
</div>
)}
{updateInfo.release_notes && !isUpdating && (
<div className="mt-2">
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Release Notes
</summary>
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
{updateInfo.release_notes.length > 200
? `${updateInfo.release_notes.substring(0, 200)}...`
: updateInfo.release_notes}
</div>
</details>
</div>
)}
</div>
</div>
);
}
+92 -2
View File
@@ -78,6 +78,11 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
const [renameError, setRenameError] = React.useState<string | null>(null);
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [deleteConfirmationName, setDeleteConfirmationName] =
React.useState("");
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isClient, setIsClient] = React.useState(false);
// Ensure we're on the client side to prevent hydration mismatches
@@ -117,6 +122,26 @@ export function ProfilesDataTable({
}
};
const handleDelete = async () => {
if (!profileToDelete || !deleteConfirmationName.trim()) return;
if (deleteConfirmationName.trim() !== profileToDelete.name) {
setDeleteError(
"Profile name doesn't match. Please type the exact name to confirm deletion.",
);
return;
}
try {
await onDeleteProfile(profileToDelete);
setProfileToDelete(null);
setDeleteConfirmationName("");
setDeleteError(null);
} catch (err) {
setDeleteError(err as string);
}
};
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
() => [
{
@@ -379,7 +404,10 @@ export function ProfilesDataTable({
Rename profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void onDeleteProfile(profile)}
onClick={() => {
setProfileToDelete(profile);
setDeleteConfirmationName("");
}}
className="text-red-600"
disabled={!isClient || isRunning || isBrowserUpdating}
>
@@ -400,7 +428,6 @@ export function ProfilesDataTable({
onLaunchProfile,
onKillProfile,
onProxySettings,
onDeleteProfile,
onChangeVersion,
],
);
@@ -514,6 +541,69 @@ export function ProfilesDataTable({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={profileToDelete !== null}
onOpenChange={(open) => {
if (!open) {
setProfileToDelete(null);
setDeleteConfirmationName("");
setDeleteError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
profile &quot;{profileToDelete?.name}&quot; and all its associated
data.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="delete-confirmation">
Please type <strong>{profileToDelete?.name}</strong> to confirm:
</Label>
<Input
id="delete-confirmation"
value={deleteConfirmationName}
onChange={(e) => {
setDeleteConfirmationName(e.target.value);
setDeleteError(null);
}}
placeholder="Type the profile name here"
/>
</div>
{deleteError && (
<p className="text-sm text-red-600">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setProfileToDelete(null);
setDeleteConfirmationName("");
setDeleteError(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={
!deleteConfirmationName.trim() ||
deleteConfirmationName !== profileToDelete?.name
}
>
Delete Profile
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { AppUpdateToast } from "@/components/app-update-toast";
import { showToast } from "@/lib/toast-utils";
import type { AppUpdateInfo } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import React, { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
export function useAppUpdateNotifications() {
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = useState<string>("");
const [isClient, setIsClient] = useState(false);
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const checkForAppUpdates = useCallback(async () => {
if (!isClient) return;
try {
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates",
);
// Don't show update if this version was already dismissed
if (update && update.new_version !== dismissedVersion) {
setUpdateInfo(update);
} else if (update) {
console.log("Update available but dismissed:", update.new_version);
}
} catch (error) {
console.error("Failed to check for app updates:", error);
}
}, [isClient, dismissedVersion]);
const checkForAppUpdatesManual = useCallback(async () => {
if (!isClient) return;
try {
console.log("Triggering manual app update check...");
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates_manual",
);
console.log("Manual check result:", update);
// Always show manual check results, even if previously dismissed
setUpdateInfo(update);
} catch (error) {
console.error("Failed to manually check for app updates:", error);
}
}, [isClient]);
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
try {
setIsUpdating(true);
setUpdateProgress("Starting update...");
await invoke("download_and_install_app_update", {
updateInfo: appUpdateInfo,
});
} catch (error) {
console.error("Failed to update app:", error);
showToast({
type: "error",
title: "Failed to update Donut Browser",
description: String(error),
duration: 6000,
});
setIsUpdating(false);
setUpdateProgress("");
}
}, []);
const dismissAppUpdate = useCallback(() => {
if (!isClient) return;
// Remember the dismissed version so we don't show it again
if (updateInfo) {
setDismissedVersion(updateInfo.new_version);
console.log("Dismissed app update version:", updateInfo.new_version);
}
setUpdateInfo(null);
toast.dismiss("app-update");
}, [isClient, updateInfo]);
// Listen for app update availability
useEffect(() => {
if (!isClient) return;
const unlistenUpdate = listen<AppUpdateInfo>(
"app-update-available",
(event) => {
console.log("App update available:", event.payload);
setUpdateInfo(event.payload);
},
);
const unlistenProgress = listen<string>("app-update-progress", (event) => {
console.log("App update progress:", event.payload);
setUpdateProgress(event.payload);
});
return () => {
void unlistenUpdate.then((unlisten) => {
unlisten();
});
void unlistenProgress.then((unlisten) => {
unlisten();
});
};
}, [isClient]);
// Show toast when update is available
useEffect(() => {
if (!isClient || !updateInfo) return;
toast.custom(
() => (
<AppUpdateToast
updateInfo={updateInfo}
onUpdate={handleAppUpdate}
onDismiss={dismissAppUpdate}
isUpdating={isUpdating}
updateProgress={updateProgress}
/>
),
{
id: "app-update",
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
},
);
}, [
updateInfo,
handleAppUpdate,
dismissAppUpdate,
isUpdating,
updateProgress,
isClient,
]);
// Check for app updates on startup
useEffect(() => {
if (!isClient) return;
// Check for updates immediately on startup
void checkForAppUpdates();
}, [isClient, checkForAppUpdates]);
return {
updateInfo,
isUpdating,
checkForAppUpdates,
checkForAppUpdatesManual,
dismissAppUpdate,
};
}
+14
View File
@@ -19,3 +19,17 @@ export interface BrowserProfile {
process_id?: number;
last_launch?: number;
}
export interface AppUpdateInfo {
current_version: string;
new_version: string;
release_notes: string;
download_url: string;
is_nightly: boolean;
published_at: string;
}
export interface AppVersionInfo {
version: string;
is_nightly: boolean;
}