Compare commits

...

22 Commits

Author SHA1 Message Date
zhom b0ca14c184 chore: version bump 2025-06-07 06:14:34 +04:00
zhom eea94ad360 chore: linter 2025-06-07 06:12:44 +04:00
zhom 2b678ed04d fix: hide brave releases without zip files for linux 2025-06-07 06:00:55 +04:00
zhom dff201ddec chore: add cursor rule 2025-06-07 05:46:32 +04:00
zhom 743ad59348 fix: consider all brave releases nightly if there is no release name 2025-06-07 05:42:57 +04:00
zhom d43e9ef21b chore: ui copy 2025-06-07 05:30:33 +04:00
zhom 7515cbacd6 refactor: check for similar-named brave binaries 2025-06-07 05:25:57 +04:00
zhom f41172e822 fix: update brave url for linux arm 2025-06-07 05:12:04 +04:00
zhom 25ce691bbc fix: use correct file extension on linux 2025-06-07 05:02:24 +04:00
zhom b945ee7088 chore: add cursor rule 2025-06-07 04:39:57 +04:00
zhom eb3589b4c0 chore: add cursor rule 2025-06-07 02:19:55 +04:00
zhom b71b9a00ca docs: update feature list in readme 2025-06-07 02:08:03 +04:00
zhom 6535b37c98 Merge pull request #17 from zhom/docs/readme-tauri-duplicate-link
docs: remove duplicate tauri link
2025-06-07 01:20:39 +04:00
zhom bc72a837e2 docs: remove duplicate tauri link 2025-06-07 01:18:27 +04:00
zhom fb84068d30 docs: add features section to readme 2025-06-07 00:30:06 +04:00
zhom 5024eab062 chore: version bump 2025-06-07 00:01:01 +04:00
zhom 8137f9bf8d fix: adjust download logic to work with latest firefox cdn 2025-06-06 23:40:51 +04:00
zhom e2547c6ec7 docs: update agent instructions 2025-06-06 23:29:10 +04:00
zhom d8d59d2bd5 fix: extraction and version detection 2025-06-06 23:22:45 +04:00
zhom b84350eb13 docs: add ai agents instruction file 2025-06-06 23:22:29 +04:00
zhom 383cef916c docs: remove duplicate star history graph 2025-06-06 14:35:13 +04:00
zhom 743bc059be docs: update readme 2025-06-06 14:33:52 +04:00
19 changed files with 744 additions and 329 deletions
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Don't leave comments that don't add value
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test"
+5
View File
@@ -0,0 +1,5 @@
# Instructions for AI Agents
- If you want to run tests, only ever run them as "pnpm format && pnpm lint && pnpm test".
- Don't leave comments that don't add value
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
+36 -10
View File
@@ -1,16 +1,38 @@
# Donut Browser
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
![Donut Browser Logo](assets/logo.png)
<p align="center">
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/v/release/zhom/donutbrowser">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/issues" target="_blank">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs Welcome">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
</p>
> **A powerful browser orchestrator that puts you in control of your browsing experience. 🍩**
## Donut Browser
[![GitHub Release](https://img.shields.io/github/v/release/zhom/donutbrowser)](https://github.com/zhom/donutbrowser/releases/latest)
[![RPs Welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://github.com/zhom/donutbrowser/issues)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://github.com/zhom/donutbrowser/blob/main/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/zhom/donutbrowser?style=social)](https://github.com/zhom/donutbrowser/stargazers)
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
![Donut Browser Preview](assets/preview.png)
## Features
- Create unlimited number of local browser profiles completely isolated from each other
- Proxy support with basic auth for all browsers except for TOR Browser
- Import profiles from your existing browsers
- Automatic updates both for browsers and for the app itself
- Set Donut Browser as your default browser to control in which profile to open links
## 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.
@@ -28,8 +50,6 @@ The app can be downloaded from the [releases page](https://github.com/zhom/donut
### Contributing
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Issues
@@ -45,7 +65,13 @@ Have questions or want to contribute? We'd love to hear from you!
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date)](https://www.star-history.com/#zhom/donutbrowser&Date)
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
</picture>
</a>
## Contact
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.3.0",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
+1 -1
View File
@@ -993,7 +993,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"async-trait",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.3.0"
version = "0.3.2"
description = "Simple Yet Powerful 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.3.0</string>
<string>0.3.2</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
+55 -80
View File
@@ -229,10 +229,41 @@ pub fn is_nightly_version(version: &str) -> bool {
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_nightly_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
/// Centralized function to determine if a browser version/release is nightly/prerelease
/// This is the single source of truth for nightly detection across the entire codebase
pub fn is_browser_version_nightly(
browser: &str,
version: &str,
release_name: Option<&str>,
) -> bool {
match browser {
"zen" => {
// For Zen Browser, only "twilight" is considered nightly
version.to_lowercase() == "twilight"
}
"brave" => {
// For Brave Browser, only releases titled "Release" are stable, everything else is nightly
if let Some(name) = release_name {
!name.starts_with("Release")
} else {
true
}
}
"firefox" | "firefox-developer" => {
// For Firefox, use the category from the API response to determine stability
// This will be handled in the API parsing, so this fallback is for cached versions
is_nightly_version(version)
}
"mullvad-browser" | "tor-browser" => is_nightly_version(version),
"chromium" => {
// Chromium builds are generally stable snapshots
false
}
_ => {
// Default fallback
is_nightly_version(version)
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -256,7 +287,6 @@ pub struct BrowserRelease {
pub version: String,
pub date: String,
pub is_prerelease: bool,
pub download_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -278,7 +308,6 @@ pub struct ApiClient {
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
}
impl ApiClient {
@@ -291,7 +320,6 @@ impl ApiClient {
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(),
}
}
@@ -302,7 +330,6 @@ impl ApiClient {
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
) -> Self {
Self {
client: Client::new(),
@@ -311,7 +338,6 @@ impl ApiClient {
github_api_base,
chromium_api_base,
tor_archive_base,
mozilla_download_base,
}
}
@@ -449,11 +475,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
is_prerelease: is_browser_version_nightly("firefox", &version, None),
}
})
.collect(),
@@ -489,10 +511,6 @@ impl ApiClient {
version: release.version.clone(),
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
None
@@ -534,11 +552,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
is_prerelease: is_browser_version_nightly("firefox-developer", &version, None),
}
})
.collect(),
@@ -580,10 +594,6 @@ impl ApiClient {
version: release.version.clone(),
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
None
@@ -685,7 +695,8 @@ impl ApiClient {
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_nightly = is_zen_nightly_version(&release.tag_name);
release.is_nightly =
is_browser_version_nightly("zen", &release.tag_name, Some(&release.name));
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
@@ -749,9 +760,9 @@ impl ApiClient {
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
if has_compatible_asset {
// Set is_nightly based on the release name
// Stable releases start with "Release", everything else is nightly
release.is_nightly = !release.name.starts_with("Release");
// Use the centralized nightly detection function
release.is_nightly =
is_browser_version_nightly("brave", &release.tag_name, Some(&release.name));
Some(release)
} else {
None
@@ -794,22 +805,17 @@ impl ApiClient {
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
}
"linux" => {
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
// For Linux, be strict about architecture matching - only allow assets that explicitly match the current architecture
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().any(|asset| {
if assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.ends_with(".deb") || name.ends_with(".rpm")
})
}) {
return true;
}
false
}
_ => false,
}
@@ -877,7 +883,6 @@ impl ApiClient {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Chromium versions are generally stable builds
download_url: None,
}
})
.collect(),
@@ -914,7 +919,6 @@ impl ApiClient {
version: version.clone(),
date: "".to_string(),
is_prerelease: false,
download_url: None,
})
.collect(),
)
@@ -934,11 +938,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
)),
is_prerelease: is_browser_version_nightly("tor-browser", &version, None),
}
})
.collect(),
@@ -1013,10 +1013,6 @@ impl ApiClient {
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(),
@@ -1065,13 +1061,11 @@ impl ApiClient {
struct TwilightInfo {
file_size: u64,
last_updated: u64,
download_url: String,
}
let current_info = TwilightInfo {
file_size: asset.size,
last_updated: Self::get_current_timestamp(),
download_url: asset.browser_download_url.clone(),
};
if !twilight_cache_file.exists() {
@@ -1137,7 +1131,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
@@ -1317,12 +1310,6 @@ mod tests {
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]
@@ -1365,12 +1352,6 @@ mod tests {
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]
@@ -1615,12 +1596,6 @@ mod tests {
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]
@@ -1693,13 +1668,13 @@ mod tests {
#[test]
fn test_is_zen_nightly_version() {
// Only "twilight" should be considered nightly for Zen Browser
assert!(is_zen_nightly_version("twilight"));
assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
assert!(is_browser_version_nightly("zen", "twilight", None));
assert!(is_browser_version_nightly("zen", "TWILIGHT", None)); // Case insensitive
// Versions with "b" should NOT be considered nightly for Zen Browser
assert!(!is_zen_nightly_version("1.12.8b"));
assert!(!is_zen_nightly_version("1.0.0b1"));
assert!(!is_zen_nightly_version("2.0.0"));
assert!(!is_browser_version_nightly("zen", "1.12.8b", None));
assert!(!is_browser_version_nightly("zen", "1.0.0b1", None));
assert!(!is_browser_version_nightly("zen", "2.0.0", None));
}
#[tokio::test]
+3 -6
View File
@@ -346,12 +346,9 @@ impl AutoUpdater {
// Helper methods
fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha")
|| version.contains("beta")
|| version.contains("rc")
|| version.contains("a")
|| version.contains("b")
|| version.contains("dev")
// Use the centralized nightly detection function
// Since we don't have browser context here, use the general fallback
crate::api_client::is_nightly_version(version)
}
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
+2
View File
@@ -307,6 +307,8 @@ mod linux {
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
browser_subdir.join("brave-browser-nightly"),
browser_subdir.join("brave-browser-beta"),
],
_ => vec![],
};
+55 -42
View File
@@ -122,7 +122,7 @@ impl BrowserVersionService {
.map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(browser, &version, None),
date: "".to_string(), // Cache doesn't store dates
}
})
@@ -240,7 +240,9 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox", &version, None,
),
date: "".to_string(),
}
}
@@ -261,7 +263,11 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox-developer",
&version,
None,
),
date: "".to_string(),
}
}
@@ -303,7 +309,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Zen Browser releases are usually stable
is_prerelease: crate::api_client::is_browser_version_nightly("zen", &version, None),
date: "".to_string(),
}
}
@@ -324,7 +330,9 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("beta") || version.contains("dev"),
is_prerelease: crate::api_client::is_browser_version_nightly(
"brave", &version, None,
),
date: "".to_string(),
}
}
@@ -360,7 +368,11 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"tor-browser",
&release.version,
None,
),
date: release.date.clone(),
}
} else {
@@ -423,11 +435,16 @@ impl BrowserVersionService {
match browser {
"firefox" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
("windows", "arm64") => (
"win64-aarch64",
format!("Firefox Setup {version}.exe"),
false,
),
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
@@ -435,27 +452,25 @@ impl BrowserVersionService {
}
};
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-{version}.exe"), false),
"linux" => (format!("firefox-{version}.tar.xz"), true),
"macos" => (format!("firefox-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US"
"https://download-installer.cdn.mozilla.net/pub/firefox/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
})
}
"firefox-developer" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
("windows", "arm64") => (
"win64-aarch64",
format!("Firefox Setup {version}.exe"),
false,
),
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
@@ -464,16 +479,9 @@ impl BrowserVersionService {
}
};
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-developer-{version}.exe"), false),
"linux" => (format!("firefox-developer-{version}.tar.xz"), true),
"macos" => (format!("firefox-developer-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox Developer: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://download.mozilla.org/?product=firefox-devedition-{version}&os={os_param}&lang=en-US"
"https://download-installer.cdn.mozilla.net/pub/devedition/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
@@ -560,8 +568,6 @@ impl BrowserVersionService {
})
}
"brave" => {
// Brave uses different asset naming conventions
// The actual URL will be resolved dynamically in the download service
let (filename, is_archive) = match (&os[..], &arch[..]) {
("windows", _) => (format!("brave-{version}.exe"), false),
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
@@ -574,7 +580,7 @@ impl BrowserVersionService {
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
),
filename,
is_archive,
@@ -826,7 +832,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
@@ -1468,16 +1473,24 @@ mod tests {
// Test Firefox
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
assert_eq!(firefox_info.filename, "firefox-139.0.dmg");
assert!(firefox_info.url.contains("firefox-139.0"));
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
assert!(firefox_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_info.url.contains("/pub/firefox/releases/139.0/"));
assert!(firefox_info.is_archive);
// Test Firefox Developer
let firefox_dev_info = service
.get_download_info("firefox-developer", "139.0b1")
.unwrap();
assert_eq!(firefox_dev_info.filename, "firefox-developer-139.0b1.dmg");
assert!(firefox_dev_info.url.contains("devedition-139.0b1"));
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
assert!(firefox_dev_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_dev_info
.url
.contains("/pub/devedition/releases/139.0b1/"));
assert!(firefox_dev_info.is_archive);
// Test Mullvad Browser
@@ -1506,10 +1519,10 @@ mod tests {
assert!(chromium_info.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive);
// Test Brave
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
assert!(brave_info.url.contains("brave-placeholder"));
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
assert!(brave_info.is_archive);
// Test unsupported browser
+5 -33
View File
@@ -195,40 +195,13 @@ impl Downloader {
})
}
"linux" => {
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
.or_else(|| {
// Fallback to DEB packages
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && name.ends_with(".deb")
})
})
.or_else(|| {
// Fallback to any ZIP
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
})
})
.or_else(|| {
// Fallback to any DEB
assets.iter().find(|asset| asset.name.ends_with(".deb"))
})
.or_else(|| {
// Last fallback to RPM if no ZIP or DEB found
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("x86_64") && name.ends_with(".rpm")
})
})
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
}
_ => None,
};
@@ -459,7 +432,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
+542 -135
View File
@@ -34,8 +34,15 @@ impl Extractor {
};
let _ = app_handle.emit("download-progress", &progress);
println!(
"Starting extraction of {} for browser {}",
archive_path.display(),
browser_type.as_str()
);
// Try to detect the actual file type by reading the file header
let actual_format = self.detect_file_format(archive_path)?;
println!("Detected format: {actual_format}");
match actual_format.as_str() {
"dmg" => {
@@ -88,6 +95,14 @@ impl Extractor {
use std::fs::File;
use std::io::Read;
// First check file extension for DMG files since they're common on macOS
// and can have misleading magic numbers
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
if ext.to_lowercase() == "dmg" {
return Ok("dmg".to_string());
}
}
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
file.read_exact(&mut buffer)?;
@@ -179,6 +194,12 @@ impl Extractor {
dmg_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!(
"Extracting DMG: {} to {}",
dmg_path.display(),
dest_dir.display()
);
// Create a temporary mount point
let mount_point = std::env::temp_dir().join(format!(
"donut_mount_{}",
@@ -189,6 +210,8 @@ impl Extractor {
));
create_dir_all(&mount_point)?;
println!("Created mount point: {}", mount_point.display());
// Mount the DMG
let output = Command::new("hdiutil")
.args([
@@ -201,42 +224,109 @@ impl Extractor {
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to mount DMG: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
return Err(format!("Failed to mount DMG: {stderr}").into());
}
// Find the .app directory 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")?;
println!("Successfully mounted DMG");
// List the contents for debugging
println!("Mount point contents:");
if let Ok(entries) = fs::read_dir(&mount_point) {
for entry in entries.flatten() {
let path = entry.path();
println!(
" - {} ({})",
path.display(),
if path.is_dir() { "dir" } else { "file" }
);
}
}
// Find the .app directory in the mount point with enhanced search
let app_result = self.find_app_in_directory(&mount_point).await;
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
println!("Failed to find .app in mount point: {e}");
// Enhanced debugging - look for any interesting files/directories
if let Ok(entries) = fs::read_dir(&mount_point) {
println!("Detailed mount point analysis:");
for entry in entries.flatten() {
let path = entry.path();
let metadata = fs::metadata(&path);
println!(
" - {} ({}) - {:?}",
path.display(),
if path.is_dir() { "dir" } else { "file" },
metadata.map(|m| m.len()).unwrap_or(0)
);
// If it's a directory, look one level deep
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten().take(5) {
// Limit to first 5 items
let sub_path = sub_entry.path();
println!(
" - {} ({})",
sub_path.display(),
if sub_path.is_dir() { "dir" } else { "file" }
);
}
}
}
}
}
// Try to unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
let _ = fs::remove_dir_all(&mount_point);
return Err("No .app found after extraction".into());
}
};
println!("Found .app bundle: {}", app_entry.display());
// Copy the .app to the destination
let app_path = dest_dir.join(app_entry.file_name());
let app_path = dest_dir.join(app_entry.file_name().unwrap());
println!("Copying .app to: {}", app_path.display());
let output = Command::new("cp")
.args([
"-R",
app_entry.path().to_str().unwrap(),
app_entry.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(),
);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
let _ = fs::remove_dir_all(&mount_point);
return Err(format!("Failed to copy app: {stderr}").into());
}
println!("Successfully copied .app bundle");
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
@@ -246,29 +336,19 @@ impl Extractor {
.args(["-cr", app_path.to_str().unwrap()])
.output();
// Try to unmount the DMG with retries
let mut retry_count = 0;
let max_retries = 3;
let mut unmounted = false;
println!("Removed quarantine attributes");
while retry_count < max_retries && !unmounted {
// Wait a bit before trying to unmount
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Unmount the DMG
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
if output.status.success() {
unmounted = true;
} else if retry_count == max_retries - 1 {
// Force unmount on last retry
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
unmounted = true; // Consider it unmounted even if force fails
}
retry_count += 1;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Warning: Failed to unmount DMG: {stderr}");
// Don't fail if unmount fails - the extraction was successful
} else {
println!("Successfully unmounted DMG");
}
// Clean up mount point directory
@@ -277,6 +357,79 @@ impl Extractor {
Ok(app_path)
}
#[cfg(target_os = "macos")]
async fn find_app_in_directory(
&self,
dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
self.find_app_recursive(dir, 0).await
}
#[cfg(target_os = "macos")]
async fn find_app_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Limit search depth to avoid infinite loops
if depth > 4 {
return Err("Maximum search depth reached".into());
}
if let Ok(entries) = fs::read_dir(dir) {
let mut subdirs = Vec::new();
let mut hidden_subdirs = Vec::new();
// First pass: look for .app bundles directly
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(extension) = path.extension() {
if extension == "app" {
println!("Found .app bundle at depth {}: {}", depth, path.display());
return Ok(path);
}
}
// Collect subdirectories for second pass
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if filename.starts_with('.') {
// Hidden directories - search these with lower priority
hidden_subdirs.push(path);
} else {
// Regular directories - search these first
subdirs.push(path);
}
}
}
// Second pass: search regular subdirectories first
for subdir in subdirs {
// Skip common directories that are unlikely to contain .app files
let dirname = subdir.file_name().unwrap_or_default().to_string_lossy();
if matches!(
dirname.as_ref(),
"Documents" | "Downloads" | "Desktop" | "Library" | "System" | "tmp" | "var"
) {
continue;
}
if let Ok(result) = Box::pin(self.find_app_recursive(&subdir, depth + 1)).await {
return Ok(result);
}
}
// Third pass: search hidden directories if nothing found in regular ones
for hidden_dir in hidden_subdirs {
if let Ok(result) = Box::pin(self.find_app_recursive(&hidden_dir, depth + 1)).await {
return Ok(result);
}
}
}
Err(format!("No .app found in directory: {}", dir.display()).into())
}
pub async fn extract_zip(
&self,
zip_path: &Path,
@@ -608,34 +761,65 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// First, try to find any .app file in the destination directory
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "app") {
return Ok(path);
}
// For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.extension().is_some_and(|ext| ext == "app") {
// Move the app to the root destination directory
let target_path = dest_dir.join(sub_path.file_name().unwrap());
fs::rename(&sub_path, &target_path)?;
println!("Searching for .app bundle in: {}", dest_dir.display());
// Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path);
return Ok(target_path);
// Use the enhanced recursive search
match self.find_app_in_directory(dest_dir).await {
Ok(app_path) => {
// Check if the app is in a subdirectory and move it to the root if needed
let app_parent = app_path.parent().unwrap();
if app_parent != dest_dir {
println!(
"Found .app in subdirectory, moving to root: {} -> {}",
app_path.display(),
dest_dir.display()
);
let target_path = dest_dir.join(app_path.file_name().unwrap());
// Move the app to the root destination directory
fs::rename(&app_path, &target_path)?;
// Try to clean up the now-empty subdirectory (ignore errors)
if let Some(parent_dir) = app_path.parent() {
if parent_dir != dest_dir {
let _ = fs::remove_dir_all(parent_dir);
}
}
println!("Successfully moved .app to: {}", target_path.display());
Ok(target_path)
} else {
println!("Found .app at root level: {}", app_path.display());
Ok(app_path)
}
}
Err(e) => {
println!("Failed to find .app bundle: {e}");
// List contents for debugging
if let Ok(entries) = fs::read_dir(dest_dir) {
println!("Destination directory contents:");
for entry in entries.flatten() {
let path = entry.path();
let metadata = if path.is_dir() { "dir" } else { "file" };
println!(" - {} ({})", path.display(), metadata);
// If it's a directory, also list its contents
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
let sub_metadata = if sub_path.is_dir() { "dir" } else { "file" };
println!(" - {} ({})", sub_path.display(), sub_metadata);
}
}
}
}
}
Err("No .app found after extraction".into())
}
}
Err("No .app found after extraction".into())
}
#[cfg(target_os = "windows")]
@@ -904,7 +1088,8 @@ impl Extractor {
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::fs::{create_dir_all, File};
use std::io::Write;
use tempfile::TempDir;
#[test]
@@ -915,50 +1100,81 @@ mod tests {
#[test]
fn test_unsupported_archive_format() {
let _ = Extractor::new();
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap();
// Create a mock app handle (this won't work in real tests without Tauri runtime)
// For now, we'll just test the logic without the actual extraction
// Create a file with invalid header
let mut file = File::create(&fake_archive).unwrap();
file.write_all(b"invalid content").unwrap();
// Test that unsupported formats return an error
let extension = fake_archive
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
assert_eq!(extension, "rar");
// We know this would fail with "Unsupported archive format: rar"
// Test format detection
let result = extractor.detect_file_format(&fake_archive);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "unknown");
}
#[test]
fn test_dmg_path_validation() {
let temp_dir = TempDir::new().unwrap();
let dmg_path = temp_dir.path().join("test.dmg");
// Test that we can identify DMG files correctly
let extension = dmg_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
assert_eq!(extension, "dmg");
}
#[test]
fn test_zip_path_validation() {
fn test_format_detection_zip() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let zip_path = temp_dir.path().join("test.zip");
// Test that we can identify ZIP files correctly
let extension = zip_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// Create a file with ZIP magic number
let mut file = File::create(&zip_path).unwrap();
file.write_all(&[0x50, 0x4B, 0x03, 0x04]).unwrap(); // ZIP magic
file.write_all(&[0; 8]).unwrap(); // padding
assert_eq!(extension, "zip");
let result = extractor.detect_file_format(&zip_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "zip");
}
#[test]
fn test_format_detection_dmg_by_extension() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let dmg_path = temp_dir.path().join("test.dmg");
// Create a file (magic number won't match, but extension will)
let mut file = File::create(&dmg_path).unwrap();
file.write_all(b"fake dmg content").unwrap();
let result = extractor.detect_file_format(&dmg_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "dmg");
}
#[test]
fn test_format_detection_exe() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let exe_path = temp_dir.path().join("test.exe");
// Create a file with PE header
let mut file = File::create(&exe_path).unwrap();
file.write_all(&[0x4D, 0x5A]).unwrap(); // PE magic
file.write_all(&[0; 10]).unwrap(); // padding
let result = extractor.detect_file_format(&exe_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "exe");
}
#[test]
fn test_format_detection_tar_gz() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let tar_gz_path = temp_dir.path().join("test.tar.gz");
// Create a file with gzip magic
let mut file = File::create(&tar_gz_path).unwrap();
file.write_all(&[0x1F, 0x8B, 0x08]).unwrap(); // gzip magic
file.write_all(&[0; 9]).unwrap(); // padding
let result = extractor.detect_file_format(&tar_gz_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "tar.gz");
}
#[test]
@@ -987,56 +1203,247 @@ mod tests {
assert!(mount_point2.to_string_lossy().contains("donut_mount_"));
}
#[test]
fn test_app_path_detection() {
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_app_at_root_level() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create a fake .app directory
let app_dir = temp_dir.path().join("TestApp.app");
std::fs::create_dir_all(&app_dir).unwrap();
// Create a Firefox.app directory
let firefox_app = temp_dir.path().join("Firefox.app");
create_dir_all(&firefox_app).unwrap();
// Test finding .app directories
let entries: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.collect();
// Create the standard macOS app structure
let contents_dir = firefox_app.join("Contents");
let macos_dir = contents_dir.join("MacOS");
create_dir_all(&macos_dir).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name(), "TestApp.app");
// Create the executable
let executable = macos_dir.join("firefox");
File::create(&executable).unwrap();
// Test finding the app
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_ok());
let found_app = result.unwrap();
assert_eq!(found_app.file_name().unwrap(), "Firefox.app");
assert!(found_app.exists());
}
#[test]
fn test_nested_app_detection() {
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_app_in_subdirectory() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create a nested structure like Chromium
let chrome_dir = temp_dir.path().join("chrome-mac");
std::fs::create_dir_all(&chrome_dir).unwrap();
// Create a nested structure like some browsers have
let subdir = temp_dir.path().join("chrome-mac");
create_dir_all(&subdir).unwrap();
let app_dir = chrome_dir.join("Chromium.app");
std::fs::create_dir_all(&app_dir).unwrap();
// Create a Brave Browser.app directory
let brave_app = subdir.join("Brave Browser.app");
create_dir_all(&brave_app).unwrap();
// Test finding nested .app directories
let mut found_app = false;
// Create the standard macOS app structure
let contents_dir = brave_app.join("Contents");
let macos_dir = contents_dir.join("MacOS");
create_dir_all(&macos_dir).unwrap();
if let Ok(entries) = fs::read_dir(temp_dir.path()) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.extension().is_some_and(|ext| ext == "app") {
found_app = true;
break;
}
}
}
}
}
// Create the executable
let executable = macos_dir.join("Brave Browser");
File::create(&executable).unwrap();
// Test finding the app
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_ok());
let found_app = result.unwrap();
assert_eq!(found_app.file_name().unwrap(), "Brave Browser.app");
assert!(found_app.exists());
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_app_multiple_levels_deep() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create a deeply nested structure
let level1 = temp_dir.path().join("level1");
let level2 = level1.join("level2");
create_dir_all(&level2).unwrap();
// Create a Mullvad Browser.app directory
let mullvad_app = level2.join("Mullvad Browser.app");
create_dir_all(&mullvad_app).unwrap();
// Create the standard macOS app structure
let contents_dir = mullvad_app.join("Contents");
let macos_dir = contents_dir.join("MacOS");
create_dir_all(&macos_dir).unwrap();
// Create the executable
let executable = macos_dir.join("firefox");
File::create(&executable).unwrap();
// Test finding the app
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_ok());
let found_app = result.unwrap();
assert_eq!(found_app.file_name().unwrap(), "Mullvad Browser.app");
assert!(found_app.exists());
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_app_no_app_found() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create some files and directories that are NOT .app bundles
let regular_dir = temp_dir.path().join("regular_directory");
create_dir_all(&regular_dir).unwrap();
let regular_file = temp_dir.path().join("regular_file.txt");
File::create(&regular_file).unwrap();
// Create a directory that looks like an app but isn't (wrong extension)
let fake_app = temp_dir.path().join("NotAnApp.app-backup");
create_dir_all(&fake_app).unwrap();
// Test that no app is found
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No .app found"));
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_app_recursive_depth_limit() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create a very deep nested structure (deeper than our limit of 4)
let mut current_path = temp_dir.path().to_path_buf();
for i in 0..6 {
current_path = current_path.join(format!("level{i}"));
create_dir_all(&current_path).unwrap();
}
assert!(found_app);
// Create an app at the deepest level
let deep_app = current_path.join("Deep.app");
create_dir_all(&deep_app).unwrap();
// Test that the app is NOT found due to depth limit
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_err());
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_find_macos_app_and_move_from_subdir() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create a nested structure where the app is in a subdirectory
let subdir = temp_dir.path().join("extracted_content");
create_dir_all(&subdir).unwrap();
// Create a Tor Browser.app directory in the subdirectory
let tor_app = subdir.join("Tor Browser.app");
create_dir_all(&tor_app).unwrap();
// Create the standard macOS app structure
let contents_dir = tor_app.join("Contents");
let macos_dir = contents_dir.join("MacOS");
create_dir_all(&macos_dir).unwrap();
// Create the executable
let executable = macos_dir.join("firefox");
File::create(&executable).unwrap();
// Test finding and moving the app
let result = extractor.find_macos_app(temp_dir.path()).await;
assert!(result.is_ok());
let found_app = result.unwrap();
assert_eq!(found_app.file_name().unwrap(), "Tor Browser.app");
// Verify the app was moved to the root level
assert_eq!(found_app.parent().unwrap(), temp_dir.path());
assert!(found_app.exists());
// Verify the original subdirectory structure was cleaned up
assert!(!subdir.exists() || fs::read_dir(&subdir).unwrap().count() == 0);
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_multiple_apps_found_returns_first() {
let extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
// Create multiple .app directories
let firefox_app = temp_dir.path().join("Firefox.app");
create_dir_all(&firefox_app).unwrap();
let chrome_app = temp_dir.path().join("Chrome.app");
create_dir_all(&chrome_app).unwrap();
// Test that we find one of them (implementation should be consistent)
let result = extractor.find_app_in_directory(temp_dir.path()).await;
assert!(result.is_ok());
let found_app = result.unwrap();
let app_name = found_app.file_name().unwrap().to_str().unwrap();
assert!(app_name == "Firefox.app" || app_name == "Chrome.app");
}
#[test]
fn test_browser_specific_app_names() {
// Test that we can identify common browser app names correctly
let common_browser_apps = [
"Firefox.app",
"Firefox Developer Edition.app",
"Brave Browser.app",
"Mullvad Browser.app",
"Tor Browser.app",
"Zen Browser.app",
"Chromium.app",
"Google Chrome.app",
];
for app_name in &common_browser_apps {
let path = std::path::Path::new(app_name);
let extension = path.extension().and_then(|ext| ext.to_str());
assert_eq!(extension, Some("app"), "Failed for {app_name}");
}
}
#[test]
fn test_edge_cases_in_path_handling() {
let temp_dir = TempDir::new().unwrap();
// Test paths with spaces and special characters
let problematic_names = [
"Firefox Developer Edition.app",
"Brave Browser.app",
"App with (parentheses).app",
"App-with-dashes.app",
"App_with_underscores.app",
];
for app_name in &problematic_names {
let app_path = temp_dir.path().join(app_name);
create_dir_all(&app_path).unwrap();
// Verify we can detect the .app extension correctly
assert!(app_path.extension().is_some_and(|ext| ext == "app"));
// Verify file_name extraction works
assert_eq!(app_path.file_name().unwrap().to_str().unwrap(), *app_name);
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.3.0",
"version": "0.3.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+1 -1
View File
@@ -293,7 +293,7 @@ export function CreateProfileDialog({
disabled={true}
className="opacity-50"
>
{displayName} (Not supported on this platform)
{displayName} (Not supported)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
+15 -15
View File
@@ -116,31 +116,31 @@ type ToastProps =
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />;
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
case "error":
return <LuTriangleAlert className="h-4 w-4 text-red-500 flex-shrink-0" />;
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
);
}
return <LuDownload className="h-4 w-4 text-blue-500 flex-shrink-0" />;
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
case "version-update":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
case "fetching":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
case "twilight-update":
return (
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
);
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
);
}
}
@@ -151,10 +151,10 @@ export function UnifiedToast(props: ToastProps) {
const progress = "progress" in props ? props.progress : undefined;
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-3 shadow-lg">
<div className="flex items-start p-3 w-full bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white leading-tight">
<p className="text-sm font-medium leading-tight text-gray-900 dark:text-white">
{title}
</p>
@@ -165,7 +165,7 @@ export function UnifiedToast(props: ToastProps) {
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="text-xs text-gray-600 dark:text-gray-300 min-w-0 flex-1">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
@@ -195,7 +195,7 @@ export function UnifiedToast(props: ToastProps) {
}}
/>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0 w-8 text-right">
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
{progress.current}/{progress.total}
</span>
</div>
@@ -211,7 +211,7 @@ export function UnifiedToast(props: ToastProps) {
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
{props.browserName} Rolling Release
</p>
)}
@@ -220,7 +220,7 @@ export function UnifiedToast(props: ToastProps) {
{/* Description */}
{description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
<p className="mt-1 text-xs leading-tight text-gray-600 dark:text-gray-300">
{description}
</p>
)}
@@ -235,7 +235,7 @@ export function UnifiedToast(props: ToastProps) {
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verifying installation...
Verifying browser files...
</p>
)}
{stage === "downloading (twilight rolling release)" && (
+2 -2
View File
@@ -458,7 +458,7 @@ export function ImportProfileDialog({
isLoading
}
>
Import Detected Profile
Import Profile
</LoadingButton>
) : (
<LoadingButton
@@ -472,7 +472,7 @@ export function ImportProfileDialog({
!manualProfileName.trim()
}
>
Import Manual Profile
Import Profile
</LoadingButton>
)}
</DialogFooter>