Compare commits

...

8 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
10 changed files with 474 additions and 96 deletions
+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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "donutbrowser",
"private": true,
"version": "0.2.1",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
+1 -1
View File
@@ -973,7 +973,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.2.1"
version = "0.2.3"
dependencies = [
"async-trait",
"base64 0.22.1",
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "donutbrowser"
version = "0.2.1"
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.2.1</string>
<string>0.2.3</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
+37 -60
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,
@@ -370,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
@@ -701,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,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.2.1",
"version": "0.2.3",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+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