Compare commits

...

14 Commits

Author SHA1 Message Date
zhom 3af581c4ab fix: prevent hydration errors for theme provider 2025-05-31 16:48:36 +04:00
zhom 7a85edfb8a test: mock network requests inside browser_version_service 2025-05-31 15:45:01 +04:00
zhom 141a5f06a4 docs: add a note that the app has automatic updates 2025-05-31 12:57:01 +04:00
zhom 7a3857c06a chore: version bump test 2025-05-31 12:53:50 +04:00
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
16 changed files with 751 additions and 128 deletions
+2 -1
View File
@@ -126,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 }}
+3
View File
@@ -94,6 +94,9 @@ 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: "nightly-${{ steps.commit.outputs.hash }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
+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 automatically checks for updates on each launch.
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.3",
"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",
+1 -1
View File
@@ -973,7 +973,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.1.0"
version = "0.2.3"
dependencies = [
"async-trait",
"base64 0.22.1",
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "donutbrowser"
version = "0.1.0"
description = "A Tauri App"
version = "0.2.3"
description = "Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
+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.3</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()
}
+236 -86
View File
@@ -6,6 +6,8 @@ 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,
@@ -47,13 +49,25 @@ impl AppAutoUpdater {
/// 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
// Otherwise, it's a nightly build
option_env!("STABLE_RELEASE").is_none()
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 Cargo.toml
/// Get current app version from build-time injection
pub fn get_current_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
// Use build-time injected version instead of CARGO_PKG_VERSION
env!("BUILD_VERSION").to_string()
}
/// Check for app updates
@@ -63,37 +77,51 @@ impl AppAutoUpdater {
let current_version = Self::get_current_version();
let is_nightly = Self::is_nightly_build();
println!("Checking for updates - Current version: {current_version}, Is nightly: {is_nightly}");
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
releases
let nightly_releases: Vec<&AppRelease> = releases
.iter()
.filter(|release| release.tag_name.starts_with("nightly-"))
.collect()
.collect();
println!("Found {} nightly releases", nightly_releases.len());
nightly_releases
} else {
// For stable builds, look for stable releases (semver format)
releases
let stable_releases: Vec<&AppRelease> = releases
.iter()
.filter(|release| {
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
})
.collect()
.collect();
println!("Found {} stable releases", stable_releases.len());
stable_releases
};
if filtered_releases.is_empty() {
println!("No releases found for build type");
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 {
@@ -105,8 +133,16 @@ impl AppAutoUpdater {
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)
@@ -134,21 +170,36 @@ impl AppAutoUpdater {
/// 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
// Compare the commit hashes (assuming format: nightly-<commit_hash>)
if let (Some(current_hash), Some(new_hash)) = (
current_version.strip_prefix("nightly-"),
new_version.strip_prefix("nightly-"),
) {
return new_hash != current_hash;
// 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;
}
// If current version doesn't have nightly prefix, it's an upgrade from stable to nightly
!current_version.starts_with("nightly-")
} else {
// For stable builds, use semantic versioning comparison
self.is_version_newer(new_version, current_version)
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)
@@ -174,24 +225,68 @@ impl AppAutoUpdater {
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
} else if cfg!(target_arch = "x86_64") {
"x64"
} else {
"unknown"
};
// Look for macOS DMG with the appropriate architecture
println!("Looking for assets with architecture: {arch}");
for asset in assets {
if asset.name.contains(".dmg") && asset.name.contains(arch) {
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());
}
}
// Fallback: look for any macOS DMG
// 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") {
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
}
@@ -277,73 +372,24 @@ impl AppAutoUpdater {
Ok(file_path)
}
/// Extract the update (DMG on macOS)
/// Extract the update using the extraction module
async fn extract_update(
&self,
dmg_path: &Path,
archive_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For DMG files on macOS, we need to mount and copy the .app
let mount_point = dest_dir.join("mount");
fs::create_dir_all(&mount_point)?;
let extractor = Extractor::new();
// Mount the DMG
let output = Command::new("hdiutil")
.args([
"attach",
"-nobrowse",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
if !output.status.success() {
return Err(
format!(
"Failed to mount DMG: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
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()),
}
// Find the .app in the mount point
let app_entry = fs::read_dir(&mount_point)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("No .app found in DMG")?;
let app_path = dest_dir.join("extracted_app");
if app_path.exists() {
fs::remove_dir_all(&app_path)?;
}
// Copy the .app to extraction directory
let output = Command::new("cp")
.args([
"-R",
app_entry.path().to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to copy app: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Unmount the DMG
let _ = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output();
Ok(app_path)
}
/// Install the update by replacing the current app
@@ -405,14 +451,59 @@ impl AppAutoUpdater {
/// 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();
// Use open command to restart the app
let _ = Command::new("open")
.args([app_path.to_str().unwrap()])
.spawn()?;
// Create a temporary restart script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.sh");
// Exit current process after a short delay
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// 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);
}
}
@@ -448,6 +539,16 @@ pub fn get_app_version_info() -> Result<(String, bool), String> {
))
}
#[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::*;
@@ -457,6 +558,10 @@ mod tests {
// 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]
@@ -502,6 +607,27 @@ mod tests {
// 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]
@@ -528,4 +654,28 @@ mod tests {
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"));
}
}
+410 -23
View File
@@ -35,6 +35,11 @@ impl BrowserVersionService {
}
}
#[cfg(test)]
pub fn new_with_api_client(api_client: ApiClient) -> Self {
Self { api_client }
}
/// Get cached browser versions immediately (returns None if no cache exists)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
self.api_client.load_cached_versions(browser)
@@ -541,6 +546,335 @@ impl BrowserVersionService {
#[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_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
)
}
fn create_test_service(api_client: ApiClient) -> BrowserVersionService {
BrowserVersionService::new_with_api_client(api_client)
}
async fn setup_firefox_mocks(server: &MockServer) {
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"
},
"firefox-137.0": {
"build_number": 1,
"category": "major",
"date": "2023-12-15",
"description": "Firefox 137.0 Release",
"is_security_driven": false,
"product": "firefox",
"version": "137.0"
}
}
}"#;
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;
}
async fn setup_firefox_dev_mocks(server: &MockServer) {
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"
},
"devedition-139.0b5": {
"build_number": 1,
"category": "major",
"date": "2024-01-10",
"description": "Firefox Developer Edition 139.0b5",
"is_security_driven": false,
"product": "devedition",
"version": "139.0b5"
}
}
}"#;
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;
}
async fn setup_mullvad_mocks(server: &MockServer) {
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",
"size": 100000000
}
]
},
{
"tag_name": "14.5a5",
"name": "Mullvad Browser 14.5a5",
"prerelease": true,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
{
"name": "mullvad-browser-macos-14.5a5.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a5.dmg",
"size": 99000000
}
]
}
]"#;
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;
}
async fn setup_zen_mocks(server: &MockServer) {
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",
"size": 120000000
}
]
},
{
"tag_name": "1.11b",
"name": "Zen Browser 1.11b",
"prerelease": false,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-1.11b.dmg",
"size": 115000000
}
]
}
]"#;
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;
}
async fn setup_brave_mocks(server: &MockServer) {
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",
"size": 200000000
}
]
},
{
"tag_name": "v1.81.8",
"name": "Brave Release 1.81.8",
"prerelease": false,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.8-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
"size": 199000000
}
]
}
]"#;
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;
}
async fn setup_chromium_mocks(server: &MockServer) {
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;
}
async fn setup_tor_mocks(server: &MockServer) {
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>
<a href="14.0.2/">14.0.2/</a>
</body>
</html>
"#;
let version_html_144 = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
</body>
</html>
"#;
let version_html_143 = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.3.dmg">tor-browser-macos-14.0.3.dmg</a>
</body>
</html>
"#;
let version_html_142 = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.2.dmg">tor-browser-macos-14.0.2.dmg</a>
</body>
</html>
"#;
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;
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_144)
.insert_header("content-type", "text/html"),
)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_143)
.insert_header("content-type", "text/html"),
)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/14.0.2/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_142)
.insert_header("content-type", "text/html"),
)
.mount(server)
.await;
}
#[tokio::test]
async fn test_browser_version_service_creation() {
@@ -550,7 +884,11 @@ mod tests {
#[tokio::test]
async fn test_fetch_firefox_versions() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
// Test with caching
let result_cached = service.fetch_browser_versions("firefox", false).await;
@@ -561,15 +899,13 @@ mod tests {
if let Ok(versions) = result_cached {
assert!(!versions.is_empty(), "Should have Firefox versions");
assert_eq!(versions[0], "139.0", "Should have latest version first");
println!(
"Firefox cached test passed. Found {versions_count} versions",
versions_count = versions.len()
);
}
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test without caching
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
assert!(
@@ -582,6 +918,7 @@ mod tests {
!versions.is_empty(),
"Should have Firefox versions without caching"
);
assert_eq!(versions[0], "139.0", "Should have latest version first");
println!(
"Firefox no-cache test passed. Found {versions_count} versions",
versions_count = versions.len()
@@ -591,7 +928,11 @@ mod tests {
#[tokio::test]
async fn test_fetch_browser_versions_with_count() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
let result = service
.fetch_browser_versions_with_count("firefox", false)
@@ -605,6 +946,10 @@ mod tests {
result.versions.len(),
"Total count should match versions length"
);
assert_eq!(
result.versions[0], "139.0",
"Should have latest version first"
);
println!(
"Firefox count test passed. Found {} versions, new: {}",
result.total_versions_count,
@@ -615,7 +960,11 @@ mod tests {
#[tokio::test]
async fn test_fetch_detailed_versions() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
let result = service
.fetch_browser_versions_detailed("firefox", false)
@@ -631,6 +980,12 @@ mod tests {
!first_version.version.is_empty(),
"Version should not be empty"
);
assert_eq!(
first_version.version, "139.0",
"Should have latest version first"
);
assert_eq!(first_version.date, "2024-01-15", "Should have correct date");
assert!(!first_version.is_prerelease, "Should be stable release");
println!(
"Firefox detailed test passed. Found {versions_count} detailed versions",
versions_count = versions.len()
@@ -640,7 +995,9 @@ mod tests {
#[tokio::test]
async fn test_unsupported_browser() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
let result = service.fetch_browser_versions("unsupported", false).await;
assert!(
@@ -658,7 +1015,11 @@ mod tests {
#[tokio::test]
async fn test_incremental_update() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
// This test might fail if there are no cached versions yet, which is fine
let result = service
@@ -678,7 +1039,20 @@ mod tests {
#[tokio::test]
async fn test_all_supported_browsers() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
// Setup all browser mocks
setup_firefox_mocks(&server).await;
setup_firefox_dev_mocks(&server).await;
setup_mullvad_mocks(&server).await;
setup_zen_mocks(&server).await;
setup_brave_mocks(&server).await;
setup_chromium_mocks(&server).await;
setup_tor_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
let browsers = vec![
"firefox",
"firefox-developer",
@@ -690,30 +1064,30 @@ mod tests {
];
for browser in browsers {
// Test that we can at least call the function without panicking
let result = service.fetch_browser_versions(browser, false).await;
match result {
Ok(versions) => {
assert!(!versions.is_empty(), "Should have versions for {browser}");
println!(
"{browser} test passed. Found {versions_count} versions",
versions_count = versions.len()
);
}
Err(e) => {
// Some browsers might fail due to network issues, but shouldn't panic
println!("{browser} test failed (network issue): {e}");
panic!("{browser} test failed: {e}");
}
}
// Small delay between requests to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
}
#[tokio::test]
async fn test_no_caching_parameter() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
// Test with caching enabled (default)
let result_cached = service.fetch_browser_versions("firefox", false).await;
@@ -722,9 +1096,6 @@ mod tests {
"Should fetch Firefox versions with caching"
);
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test with caching disabled (no_caching = true)
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
assert!(
@@ -742,6 +1113,10 @@ mod tests {
!no_cache_versions.is_empty(),
"No-cache versions should not be empty"
);
assert_eq!(
cached_versions, no_cache_versions,
"Both should return same versions"
);
println!(
"No-caching test passed. Cached: {} versions, No-cache: {} versions",
cached_versions.len(),
@@ -752,7 +1127,11 @@ mod tests {
#[tokio::test]
async fn test_detailed_versions_with_no_caching() {
let service = BrowserVersionService::new();
let server = setup_mock_server().await;
setup_firefox_mocks(&server).await;
let api_client = create_test_api_client(&server);
let service = create_test_service(api_client);
// Test detailed versions with caching
let result_cached = service
@@ -763,9 +1142,6 @@ mod tests {
"Should fetch detailed Firefox versions with caching"
);
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test detailed versions without caching
let result_no_cache = service
.fetch_browser_versions_detailed("firefox", true)
@@ -799,6 +1175,17 @@ mod tests {
"No-cache version should not be empty"
);
assert_eq!(first_cached.version, "139.0", "Should have correct version");
assert_eq!(
first_no_cache.version, "139.0",
"Should have correct version"
);
assert_eq!(first_cached.date, "2024-01-15", "Should have correct date");
assert_eq!(
first_no_cache.date, "2024-01-15",
"Should have correct date"
);
println!(
"Detailed no-caching test passed. Cached: {} versions, No-cache: {} versions",
cached_versions.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,
+9 -2
View File
@@ -54,7 +54,8 @@ use auto_updater::{
};
use app_auto_updater::{
check_for_app_updates, download_and_install_app_update, get_app_version_info,
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
#[tauri::command]
@@ -183,6 +184,7 @@ pub fn run() {
// 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)) => {
@@ -191,7 +193,11 @@ pub fn run() {
update_info.current_version, update_info.new_version
);
// Emit update available event to the frontend
let _ = app_handle_update.emit("app-update-available", &update_info);
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");
@@ -255,6 +261,7 @@ pub fn run() {
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,
])
+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.3",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+1
View File
@@ -56,6 +56,7 @@ export default function Home() {
// App auto-update functionality
const appUpdateNotifications = useAppUpdateNotifications();
const { checkForAppUpdatesManual } = appUpdateNotifications;
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
+17 -5
View File
@@ -27,6 +27,11 @@ function getSystemTheme(): string {
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const loadTheme = async () => {
@@ -65,11 +70,18 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
}, []);
if (isLoading) {
// Detect system theme to show appropriate loading screen
const systemTheme = getSystemTheme();
const loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
const spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
// Use a consistent loading screen that doesn't depend on system theme during SSR
// This prevents hydration mismatch by ensuring server and client render the same initially
let loadingBgColor = "bg-white";
let spinnerColor = "border-gray-900";
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
}
return (
<div
+41 -2
View File
@@ -13,6 +13,7 @@ export function useAppUpdateNotifications() {
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(() => {
@@ -26,10 +27,33 @@ export function useAppUpdateNotifications() {
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates",
);
setUpdateInfo(update);
// 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) => {
@@ -56,9 +80,15 @@ export function useAppUpdateNotifications() {
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]);
}, [isClient, updateInfo]);
// Listen for app update availability
useEffect(() => {
@@ -116,10 +146,19 @@ export function useAppUpdateNotifications() {
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,
};
}