feat: linux support preview

This commit is contained in:
zhom
2025-06-05 21:15:05 +04:00
parent 6836d73ffa
commit 0da34f04cb
39 changed files with 3877 additions and 942 deletions
+154 -60
View File
@@ -224,13 +224,13 @@ pub fn sort_github_releases(releases: &mut [GithubRelease]) {
});
}
pub fn is_alpha_version(version: &str) -> bool {
pub fn is_nightly_version(version: &str) -> bool {
let version_comp = VersionComponent::parse(version);
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_alpha_version(version: &str) -> bool {
pub fn is_zen_nightly_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
}
@@ -449,7 +449,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -467,7 +467,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -534,7 +534,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -552,7 +552,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -624,13 +624,13 @@ impl ApiClient {
println!("Fetching Mullvad releases from GitHub API...");
let url = format!(
"{}/repos/mullvad/mullvad-browser/releases",
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
@@ -639,7 +639,7 @@ impl ApiClient {
let mut releases: Vec<GithubRelease> = releases
.into_iter()
.map(|mut release| {
release.is_alpha = release.prerelease;
release.is_nightly = release.prerelease;
release
})
.collect();
@@ -670,13 +670,13 @@ impl ApiClient {
println!("Fetching Zen releases from GitHub API...");
let url = format!(
"{}/repos/zen-browser/desktop/releases",
"{}/repos/zen-browser/desktop/releases?per_page=100",
self.github_api_base
);
let mut releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
@@ -684,8 +684,8 @@ impl ApiClient {
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser
release.is_alpha = is_zen_alpha_version(&release.tag_name) || release.prerelease;
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_nightly = is_zen_nightly_version(&release.tag_name);
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
@@ -726,32 +726,32 @@ impl ApiClient {
println!("Fetching Brave releases from GitHub API...");
let url = format!(
"{}/repos/brave/brave-browser/releases",
"{}/repos/brave/brave-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
.await?;
// Filter releases that have universal macOS DMG assets
// Get platform info to filter appropriate releases
let (os, arch) = Self::get_platform_info();
// Filter releases that have assets compatible with the current platform
let mut filtered_releases: Vec<GithubRelease> = releases
.into_iter()
.filter_map(|mut release| {
// Check if this release has a universal DMG asset
let has_universal_dmg = release
.assets
.iter()
.any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"));
// Check if this release has compatible assets for the current platform
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
if has_universal_dmg {
// Set is_alpha based on the release name
// Nightly releases contain "Nightly", stable contain "Release"
release.is_alpha = release.name.to_lowercase().contains("nightly");
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");
Some(release)
} else {
None
@@ -772,6 +772,83 @@ impl ApiClient {
Ok(filtered_releases)
}
/// Check if a Brave release has compatible assets for the given platform and architecture
fn has_compatible_brave_asset(
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> bool {
match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.any(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
|| assets.iter().any(|asset| asset.name.ends_with(".exe"))
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.any(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
|| assets.iter().any(|asset| asset.name.ends_with(".dmg"))
}
"linux" => {
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
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")
})
}
_ => false,
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
pub async fn fetch_chromium_latest_version(
&self,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -785,7 +862,7 @@ impl ApiClient {
let version = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -885,7 +962,7 @@ impl ApiClient {
let html = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -965,7 +1042,7 @@ impl ApiClient {
let html = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -1032,12 +1109,31 @@ impl ApiClient {
Ok(false) // No update detected
}
pub fn clear_all_cache(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
if cache_dir.exists() {
// Remove all cache files
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
fs::remove_file(&path)?;
println!("Removed cache file: {path:?}");
}
}
println!("All version cache cleared successfully");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -1215,7 +1311,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1226,6 +1321,9 @@ mod tests {
let result = client.fetch_firefox_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Firefox API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1259,7 +1357,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1272,6 +1369,9 @@ mod tests {
.fetch_firefox_developer_releases_with_caching(true)
.await;
if let Err(e) = &result {
println!("Firefox Developer API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1307,7 +1407,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1322,7 +1422,7 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_alpha);
assert!(releases[0].is_nightly);
}
#[tokio::test]
@@ -1348,7 +1448,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1388,7 +1488,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1399,11 +1499,14 @@ mod tests {
let result = client.fetch_brave_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Brave API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(!releases[0].is_alpha);
assert!(!releases[0].is_nightly);
}
#[tokio::test]
@@ -1419,7 +1522,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1448,7 +1550,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1491,7 +1592,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -1502,7 +1602,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1513,7 +1612,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3"))
@@ -1551,7 +1649,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1581,7 +1678,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.5/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1597,24 +1693,24 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
assert!(is_alpha_version("1.2.3a1"));
assert!(is_alpha_version("137.0b5"));
assert!(is_alpha_version("140.0rc1"));
assert!(!is_alpha_version("139.0"));
assert!(!is_alpha_version("1.2.3"));
fn test_is_nightly_version() {
assert!(is_nightly_version("1.2.3a1"));
assert!(is_nightly_version("137.0b5"));
assert!(is_nightly_version("140.0rc1"));
assert!(!is_nightly_version("139.0"));
assert!(!is_nightly_version("1.2.3"));
}
#[test]
fn test_is_zen_alpha_version() {
// Only "twilight" should be considered alpha for Zen Browser
assert!(is_zen_alpha_version("twilight"));
assert!(is_zen_alpha_version("TWILIGHT")); // Case insensitive
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
// Versions with "b" should NOT be considered alpha for Zen Browser
assert!(!is_zen_alpha_version("1.12.8b"));
assert!(!is_zen_alpha_version("1.0.0b1"));
assert!(!is_zen_alpha_version("2.0.0"));
// 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"));
}
#[tokio::test]
@@ -1624,7 +1720,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -1640,7 +1735,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("invalid json")
@@ -1660,7 +1754,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
.mount(&server)
.await;
+94 -21
View File
@@ -156,7 +156,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -227,6 +227,45 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
println!("Looking for macOS universal binary assets");
for asset in assets {
println!("Found asset: {}", asset.name);
}
// Priority 1: Look for universal macOS DMG (preferred)
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains("universal")
|| asset.name.contains("Universal")
|| asset.name.contains("_universal.dmg")
|| asset.name.contains("-universal.dmg")
|| asset.name.contains("_universal_")
|| asset.name.contains("-universal-"))
{
println!("Found universal binary: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Priority 2: Look for generic macOS DMG without architecture specification
// This would be the case for universal binaries that don't explicitly mention "universal"
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
|| asset.name.to_lowercase().contains("darwin"))
&& !asset.name.contains("x64")
&& !asset.name.contains("x86_64")
&& !asset.name.contains("x86-64")
&& !asset.name.contains("aarch64")
&& !asset.name.contains("arm64")
&& !asset.name.contains(".app.tar.gz")
{
println!("Found generic macOS DMG (likely universal): {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Priority 3: Fallback to current architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
@@ -235,12 +274,9 @@ impl AppAutoUpdater {
"unknown"
};
println!("Looking for assets with architecture: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
println!("Falling back to architecture-specific search for: {arch}");
// Priority 1: Look for exact architecture match in DMG
// Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg"))
@@ -253,7 +289,7 @@ impl AppAutoUpdater {
}
}
// Priority 2: Look for x86_64 variations if we're looking for x64
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -265,7 +301,7 @@ impl AppAutoUpdater {
}
}
// Priority 3: Look for arm64 variations if we're looking for aarch64
// Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -277,7 +313,7 @@ impl AppAutoUpdater {
}
}
// Priority 4: Fallback to any macOS DMG
// Priority 4: Final fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
@@ -356,7 +392,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -390,7 +426,16 @@ impl AppAutoUpdater {
.unwrap_or("");
match extension {
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
"dmg" => {
#[cfg(target_os = "macos")]
{
extractor.extract_dmg(archive_path, dest_dir).await
}
#[cfg(not(target_os = "macos"))]
{
Err("DMG extraction is only supported on macOS".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
@@ -535,14 +580,6 @@ pub async fn download_and_install_app_update(
.map_err(|e| format!("Failed to install app update: {e}"))
}
#[tauri::command]
pub fn get_app_version_info() -> Result<(String, bool), String> {
Ok((
AppAutoUpdater::get_current_version(),
AppAutoUpdater::is_nightly_build(),
))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
@@ -651,14 +688,50 @@ mod tests {
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_universal.dmg".to_string(),
browser_download_url: "https://example.com/universal.dmg".to_string(),
size: 12345,
},
];
let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// The exact URL depends on the target architecture
// Should prefer universal binary over architecture-specific ones
let url = url.unwrap();
assert!(url.contains(".dmg"));
assert_eq!(url, "https://example.com/universal.dmg");
// Test with generic macOS DMG (no architecture specified)
let generic_assets = vec![AppReleaseAsset {
name: "Donut.Browser_0.1.0_macos.dmg".to_string(),
browser_download_url: "https://example.com/macos.dmg".to_string(),
size: 12345,
}];
let generic_url = updater.get_download_url_for_platform(&generic_assets);
assert!(generic_url.is_some());
assert_eq!(generic_url.unwrap(), "https://example.com/macos.dmg");
// Test fallback to architecture-specific when no universal is available
let arch_specific_assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let arch_url = updater.get_download_url_for_platform(&arch_specific_assets);
assert!(arch_url.is_some());
// The exact URL depends on the target architecture, but should be one of the available ones
let arch_url = arch_url.unwrap();
assert!(arch_url.contains(".dmg"));
}
#[test]
+53 -63
View File
@@ -112,7 +112,7 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_stable = !self.is_alpha_version(current_version);
let is_current_stable = !self.is_nightly_version(current_version);
// Find the best available update
let best_update = available_versions
@@ -218,40 +218,6 @@ impl AutoUpdater {
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Start browser update process
pub async fn start_browser_update(
&self,
browser: &str,
new_version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Add browser to disabled list to prevent conflicts during update
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.insert(browser.to_string());
// Mark this download as auto-update for toast suppression
let download_key = format!("{browser}-{new_version}");
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
// The actual download will be triggered by the frontend
// This function now just marks the browser as updating to prevent conflicts
Ok(())
}
/// Complete browser update process
pub async fn complete_browser_update(
&self,
browser: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Remove browser from disabled list
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.remove(browser);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
@@ -312,9 +278,51 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
// Check if auto-delete of unused binaries is enabled and perform cleanup
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background - don't fail the update if cleanup fails
if let Err(e) = self.cleanup_unused_binaries_internal() {
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
}
}
Ok(updated_profiles)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Load current profiles
let profiles = self
.browser_runner
.list_profiles()
.map_err(|e| format!("Failed to load profiles: {e}"))?;
// Load registry
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
// Get active browser versions
let active_versions = registry.get_active_browser_versions(&profiles);
// Cleanup unused binaries
let cleaned_up = registry
.cleanup_unused_binaries(&active_versions)
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
// Save updated registry
registry
.save()
.map_err(|e| format!("Failed to save registry: {e}"))?;
Ok(cleaned_up)
}
/// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled(
&self,
@@ -337,7 +345,7 @@ impl AutoUpdater {
// Helper methods
fn is_alpha_version(&self, version: &str) -> bool {
fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha")
|| version.contains("beta")
|| version.contains("rc")
@@ -414,24 +422,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped)
}
#[tauri::command]
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.start_browser_update(&browser, &new_version)
.await
.map_err(|e| format!("Failed to start browser update: {e}"))
}
#[tauri::command]
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.complete_browser_update(&browser)
.await
.map_err(|e| format!("Failed to complete browser update: {e}"))
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
@@ -509,18 +499,18 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
fn test_is_nightly_version() {
let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev"));
assert!(updater.is_nightly_version("1.0.0-alpha"));
assert!(updater.is_nightly_version("1.0.0-beta"));
assert!(updater.is_nightly_version("1.0.0-rc"));
assert!(updater.is_nightly_version("1.0.0a1"));
assert!(updater.is_nightly_version("1.0.0b1"));
assert!(updater.is_nightly_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3"));
assert!(!updater.is_nightly_version("1.0.0"));
assert!(!updater.is_nightly_version("1.2.3"));
}
#[test]
+524 -136
View File
@@ -50,7 +50,6 @@ impl BrowserType {
}
pub trait Browser: Send + Sync {
fn browser_type(&self) -> BrowserType;
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
&self,
@@ -59,24 +58,17 @@ pub trait Browser: Send + Sync {
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
// Platform-specific modules
#[cfg(target_os = "macos")]
mod macos {
use super::*;
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
pub fn get_firefox_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
@@ -106,6 +98,439 @@ impl Browser for FirefoxBrowser {
Ok(executable_path)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On macOS, no special preparation needed
Ok(())
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::os::unix::fs::PermissionsExt;
pub fn get_firefox_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
// Try firefox first (preferred), then firefox-bin
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
]
}
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("tor-browser"),
browser_subdir.join("firefox-bin"),
]
}
_ => vec![],
};
for executable_path in &possible_executables {
if executable_path.exists() && executable_path.is_file() {
return Ok(executable_path.clone());
}
}
Err(
format!(
"Firefox executable not found in {}/{}",
install_dir.display(),
browser_type.as_str()
)
.into(),
)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
],
_ => vec![],
};
for executable_path in &possible_executables {
if executable_path.exists() && executable_path.is_file() {
return Ok(executable_path.clone());
}
}
Err(
format!(
"Chromium executable not found in {}/{}",
install_dir.display(),
browser_type.as_str()
)
.into(),
)
}
pub fn is_firefox_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
if !browser_subdir.exists() || !browser_subdir.is_dir() {
return false;
}
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
browser_subdir.join("tor-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
if !browser_subdir.exists() || !browser_subdir.is_dir() {
return false;
}
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
],
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
false
}
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
println!("Setting execute permissions for: {:?}", executable_path);
let metadata = std::fs::metadata(executable_path)?;
let mut permissions = metadata.permissions();
// Add execute permissions for owner, group, and others
let mode = permissions.mode();
permissions.set_mode(mode | 0o755);
std::fs::set_permissions(executable_path, permissions)?;
println!(
"Execute permissions set successfully for: {:?}",
executable_path
);
Ok(())
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn get_firefox_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for firefox.exe
let possible_paths = [
install_dir.join("firefox.exe"),
install_dir.join("firefox").join("firefox.exe"),
install_dir.join("bin").join("firefox.exe"),
];
for path in &possible_paths {
if path.exists() && path.is_file() {
return Ok(path.clone());
}
}
// Look for any .exe file that might be the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.contains("browser")
{
return Ok(path);
}
}
}
}
Err("Firefox executable not found in Windows installation directory".into())
}
pub fn get_chromium_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for .exe files
let possible_paths = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
],
_ => vec![],
};
for path in &possible_paths {
if path.exists() && path.is_file() {
return Ok(path.clone());
}
}
// Look for any .exe file that might be the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.contains("chromium") || name.contains("brave") || name.contains("chrome") {
return Ok(path);
}
}
}
}
Err("Chromium/Brave executable not found in Windows installation directory".into())
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
// On Windows, check for .exe files
let possible_executables = [
install_dir.join("firefox.exe"),
install_dir.join("firefox").join("firefox.exe"),
install_dir.join("bin").join("firefox.exe"),
];
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
// Check for any .exe file that looks like a browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.contains("browser")
{
return true;
}
}
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// On Windows, check for .exe files
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
],
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
// Check for any .exe file that looks like the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.contains("chromium") || name.contains("brave") || name.contains("chrome") {
return true;
}
}
}
}
false
}
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Windows, no special preparation needed
Ok(())
}
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
@@ -135,34 +560,52 @@ impl Browser for FirefoxBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
// Only check if directory exists and contains a .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
return true;
}
}
}
println!("No .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
@@ -173,34 +616,18 @@ impl ChromiumBrowser {
}
impl Browser for ChromiumBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
Ok(executable_path)
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
@@ -240,35 +667,46 @@ impl Browser for ChromiumBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Chromium browser checking version {version} in directory: {browser_dir:?}");
// Check if directory exists and contains at least one .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
// Try to get the executable path as a final verification
if self.get_executable_path(&browser_dir).is_ok() {
println!(" Executable path verification successful");
return true;
} else {
println!(" Executable path verification failed");
}
}
}
}
println!("No valid .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
@@ -294,7 +732,7 @@ pub struct GithubRelease {
#[serde(default)]
pub published_at: String,
#[serde(default)]
pub is_alpha: bool,
pub is_nightly: bool,
#[serde(default)]
pub prerelease: bool,
}
@@ -354,56 +792,6 @@ mod tests {
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive
}
#[test]
fn test_firefox_browser_creation() {
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = FirefoxBrowser::new(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
}
#[test]
fn test_chromium_browser_creation() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = ChromiumBrowser::new(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_browser_factory() {
// Test Firefox-based browsers
let browser = create_browser(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = create_browser(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = create_browser(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
let browser = create_browser(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = create_browser(BrowserType::FirefoxDeveloper);
assert_eq!(browser.browser_type(), BrowserType::FirefoxDeveloper);
// Test Chromium-based browsers
let browser = create_browser(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = create_browser(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
@@ -509,7 +897,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
@@ -521,7 +909,7 @@ mod tests {
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
// Test with Chromium browser
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).unwrap();
let chromium_app_dir = chromium_dir.join("Chromium.app");
@@ -544,7 +932,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create browser directory but no .app directory
// Create browser directory but no .app directory with new path structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
+255 -100
View File
@@ -70,6 +70,14 @@ mod macos {
}
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
Ok(Command::new(executable_path).args(args).spawn()?)
}
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
@@ -484,6 +492,17 @@ mod windows {
false
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!(
"Launching browser on Windows: {:?} with args: {:?}",
executable_path, args
);
Ok(Command::new(executable_path).args(args).spawn()?)
}
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
@@ -580,6 +599,126 @@ mod linux {
false
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!(
"Launching browser on Linux: {:?} with args: {:?}",
executable_path, args
);
// Check if the executable exists and is executable
if !executable_path.exists() {
return Err(format!("Browser executable not found: {:?}", executable_path).into());
}
// Check if we can read the executable to detect architecture issues early
if let Err(e) = std::fs::File::open(executable_path) {
return Err(format!("Cannot access browser executable: {}", e).into());
}
// Ensure the executable has proper permissions
if let Err(e) = std::fs::metadata(executable_path) {
return Err(format!("Cannot get executable metadata: {}", e).into());
}
// On Linux, we might need to set LD_LIBRARY_PATH for some browsers
let mut cmd = Command::new(executable_path);
cmd.args(args);
// For Firefox-based browsers, ensure library path includes the installation directory
if let Some(install_dir) = executable_path.parent() {
let mut ld_library_path = Vec::new();
// Add multiple potential library directories
let lib_dirs = [
install_dir.join("lib"),
install_dir.join("../lib"), // Parent directory lib
install_dir.join("../../lib"), // Grandparent directory lib
install_dir.to_path_buf(), // Installation directory itself
];
for lib_dir in &lib_dirs {
if lib_dir.exists() {
ld_library_path.push(lib_dir.to_string_lossy().to_string());
}
}
// For Firefox specifically, add common system library paths that might be needed
let firefox_lib_paths = [
"/usr/lib/firefox",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
"/lib/x86_64-linux-gnu",
"/lib/aarch64-linux-gnu",
];
for lib_path in &firefox_lib_paths {
let path = std::path::Path::new(lib_path);
if path.exists() {
ld_library_path.push(lib_path.to_string());
}
}
// Preserve existing LD_LIBRARY_PATH
if let Ok(existing_path) = std::env::var("LD_LIBRARY_PATH") {
ld_library_path.push(existing_path);
}
// Set the combined LD_LIBRARY_PATH
if !ld_library_path.is_empty() {
cmd.env("LD_LIBRARY_PATH", ld_library_path.join(":"));
println!("Set LD_LIBRARY_PATH to: {}", ld_library_path.join(":"));
}
}
// Additional Linux-specific environment variables for better compatibility
cmd.env(
"DISPLAY",
std::env::var("DISPLAY").unwrap_or(":0".to_string()),
);
// Set MOZ_ENABLE_WAYLAND for better Wayland support
if std::env::var("WAYLAND_DISPLAY").is_ok() {
cmd.env("MOZ_ENABLE_WAYLAND", "1");
}
// Disable GPU acceleration if running in headless environments
if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() {
println!("No display detected, browser may fail to start");
}
// Attempt to spawn with better error handling for architecture issues
match cmd.spawn() {
Ok(child) => Ok(child),
Err(e) => {
// Detect architecture mismatch errors
if e.kind() == std::io::ErrorKind::Other {
let error_msg = e.to_string();
if error_msg.contains("Exec format error") {
return Err(format!(
"Architecture mismatch: The browser executable is not compatible with your system architecture ({}). \
This typically happens when trying to run x86_64 binaries on ARM64 systems. \
Please use a browser that supports your architecture, such as Zen Browser or Brave. \
Executable: {:?}",
std::env::consts::ARCH,
executable_path
).into());
} else if error_msg.contains("No such file or directory") {
return Err(format!(
"Executable or required library not found. This might be due to missing dependencies or incorrect executable path. \
Try installing missing libraries or verify the browser installation. \
Executable: {:?}, Error: {}",
executable_path, error_msg
).into());
}
}
Err(format!("Failed to launch browser: {}", e).into())
}
}
}
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
@@ -854,9 +993,38 @@ impl BrowserRunner {
// Save the updated profile
self.save_profile(&profile)?;
// Check if auto-delete of unused binaries is enabled
let settings_manager = crate::settings_manager::SettingsManager::new();
if let Ok(settings) = settings_manager.load_settings() {
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background
let _ = self.cleanup_unused_binaries_internal();
}
}
Ok(profile)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Load current profiles
let profiles = self.list_profiles()?;
// Load registry
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()?;
// Get active browser versions
let active_versions = registry.get_active_browser_versions(&profiles);
// Cleanup unused binaries
let cleaned_up = registry.cleanup_unused_binaries(&active_versions)?;
// Save updated registry
registry.save()?;
Ok(cleaned_up)
}
fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![
// Disable default browser check
@@ -1018,7 +1186,7 @@ impl BrowserRunner {
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone());
// Get executable path
// Get executable path - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&profile.browser);
browser_dir.push(&profile.version);
@@ -1027,13 +1195,40 @@ impl BrowserRunner {
.get_executable_path(&browser_dir)
.expect("Failed to get executable path");
// Prepare the executable (set permissions, etc.)
if let Err(e) = browser.prepare_executable(&executable_path) {
println!("Warning: Failed to prepare executable: {e}");
// Continue anyway, the error might not be critical
}
// Get launch arguments
let browser_args = browser
.create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url)
.expect("Failed to create launch arguments");
// Launch browser
let child = Command::new(executable_path).args(&browser_args).spawn()?;
// Launch browser using platform-specific method
let child = {
#[cfg(target_os = "macos")]
{
macos::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "windows")]
{
windows::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "linux")]
{
linux::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return Err("Unsupported platform for browser launching".into());
}
};
let launcher_pid = child.id();
println!(
@@ -1159,7 +1354,7 @@ impl BrowserRunner {
let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
// Get browser directory for all platforms
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version);
@@ -1385,7 +1580,7 @@ impl BrowserRunner {
profile.name = new_name.to_string();
// Create new paths
let _new_profile_file = profiles_dir.join(format!(
let _ = profiles_dir.join(format!(
"{}.json",
new_name.to_lowercase().replace(" ", "_")
));
@@ -1410,44 +1605,6 @@ impl BrowserRunner {
Ok(profile)
}
pub fn get_saved_mullvad_releases(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut data_path = self.base_dirs.data_local_dir().to_path_buf();
data_path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
data_path.push("data");
let releases_file = data_path.join("mullvad_releases.json");
if !releases_file.exists() {
return Ok(vec![]);
}
let mut versions = Vec::new();
let mut browser_dir = self.base_dirs.data_local_dir().to_path_buf();
browser_dir.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
browser_dir.push("binaries");
browser_dir.push("mullvad-browser");
for entry in fs::read_dir(browser_dir)? {
let entry = entry?;
if entry.path().is_dir() {
if let Some(version_str) = entry.file_name().to_str() {
versions.push(version_str.to_string());
}
}
}
// Sort versions in descending order (newest first)
versions.sort_by(|a, b| b.cmp(a));
Ok(versions)
}
fn save_process_info(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir();
let profile_file = profiles_dir.join(format!(
@@ -1477,6 +1634,15 @@ impl BrowserRunner {
fs::remove_file(profile_file)?
}
// Check if auto-delete of unused binaries is enabled
let settings_manager = crate::settings_manager::SettingsManager::new();
if let Ok(settings) = settings_manager.load_settings() {
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background after profile deletion
let _ = self.cleanup_unused_binaries_internal();
}
}
Ok(())
}
@@ -1802,7 +1968,16 @@ pub async fn launch_browser_profile(
let updated_profile = browser_runner
.launch_or_open_url(app_handle.clone(), &profile, url)
.await
.expect("Failed to launch browser or open URL");
.map_err(|e| {
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
if io_error.kind() == std::io::ErrorKind::Other
&& io_error.to_string().contains("Exec format error") {
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
}
}
format!("Failed to launch browser or open URL: {e}")
})?;
// If the profile has proxy settings, start a proxy for it
if let Some(proxy) = &profile.proxy {
@@ -1839,15 +2014,6 @@ pub async fn launch_browser_profile(
Ok(updated_profile)
}
// Add Tauri command to get saved releases
#[tauri::command]
pub fn get_saved_mullvad_releases() -> Result<Vec<String>, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.get_saved_mullvad_releases()
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_profile_proxy(
profile_name: String,
@@ -1903,27 +2069,17 @@ pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Re
}
#[tauri::command]
pub fn get_supported_browsers() -> Result<Vec<&'static str>, String> {
Ok(vec![
BrowserType::MullvadBrowser.as_str(),
BrowserType::Firefox.as_str(),
BrowserType::FirefoxDeveloper.as_str(),
BrowserType::Chromium.as_str(),
BrowserType::Brave.as_str(),
BrowserType::Zen.as_str(),
BrowserType::TorBrowser.as_str(),
])
pub fn get_supported_browsers() -> Result<Vec<String>, String> {
let service = BrowserVersionService::new();
Ok(service.get_supported_browsers())
}
#[tauri::command]
pub async fn fetch_browser_versions_detailed(
browser_str: String,
) -> Result<Vec<BrowserVersionInfo>, String> {
pub fn is_browser_supported_on_platform(browser_str: String) -> Result<bool, String> {
let service = BrowserVersionService::new();
service
.fetch_browser_versions_detailed(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
.is_browser_supported(&browser_str)
.map_err(|e| format!("Failed to check browser support: {e}"))
}
#[tauri::command]
@@ -1996,20 +2152,6 @@ pub async fn fetch_browser_versions_with_count_cached_first(
}
}
#[tauri::command]
pub fn get_cached_browser_versions_detailed(
browser_str: String,
) -> Result<Option<Vec<BrowserVersionInfo>>, String> {
let service = BrowserVersionService::new();
Ok(service.get_cached_browser_versions_detailed(&browser_str))
}
#[tauri::command]
pub fn should_update_browser_cache(browser_str: String) -> Result<bool, String> {
let service = BrowserVersionService::new();
Ok(service.should_update_cache(&browser_str))
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -2029,8 +2171,22 @@ pub async fn download_browser(
return Ok(version);
}
// Use the centralized browser version service for download info
// Check if browser is supported on current platform before attempting download
let version_service = BrowserVersionService::new();
if !version_service
.is_browser_supported(&browser_str)
.unwrap_or(false)
{
return Err(format!(
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
browser_str,
std::env::consts::OS,
std::env::consts::ARCH,
version_service.get_supported_browsers().join(", ")
));
}
let download_info = version_service
.get_download_info(&browser_str, &version)
.map_err(|e| format!("Failed to get download info: {e}"))?;
@@ -2203,15 +2359,6 @@ pub fn create_browser_profile_new(
create_browser_profile(name, browser_type.as_str().to_string(), version, proxy)
}
#[tauri::command]
pub async fn fetch_browser_versions(browser_str: String) -> Result<Vec<String>, String> {
let service = BrowserVersionService::new();
service
.fetch_browser_versions(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
}
#[tauri::command]
pub async fn fetch_browser_versions_with_count(
browser_str: String,
@@ -2230,6 +2377,14 @@ pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String
Ok(registry.get_downloaded_versions(&browser_str))
}
#[tauri::command]
pub fn cleanup_unused_binaries() -> Result<Vec<String>, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.cleanup_unused_binaries_internal()
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2367,7 +2522,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile
let _profile = runner
let _ = runner
.create_profile("Original Name", "firefox", "139.0", None)
.unwrap();
@@ -2387,7 +2542,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile
let _profile = runner
let _ = runner
.create_profile("To Delete", "firefox", "139.0", None)
.unwrap();
@@ -2421,13 +2576,13 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create multiple profiles
let _profile1 = runner
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.unwrap();
let _profile2 = runner
let _ = runner
.create_profile("Profile 2", "chromium", "1465660", None)
.unwrap();
let _profile3 = runner
let _ = runner
.create_profile("Profile 3", "brave", "v1.81.9", None)
.unwrap();
@@ -2446,10 +2601,10 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Test that we can't rename to an existing profile name
let _profile1 = runner
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.unwrap();
let _profile2 = runner
let _ = runner
.create_profile("Profile 2", "firefox", "139.0", None)
.unwrap();
+334 -154
View File
@@ -40,6 +40,70 @@ impl BrowserVersionService {
Self { api_client }
}
/// Check if a browser is supported on the current platform and architecture
pub fn is_browser_supported(
&self,
browser: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser {
"firefox" | "firefox-developer" => Ok(true),
"mullvad-browser" => {
// Mullvad doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
"zen" => {
// Zen supports all platforms and architectures
Ok(true)
}
"brave" => {
// Brave supports all platforms and architectures
Ok(true)
}
"chromium" => {
// Chromium doesn't support ARM64 on Linux
if arch == "arm64" && os == "linux" {
Ok(false)
} else {
Ok(true)
}
}
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
_ => Err(format!("Unknown browser: {browser}").into()),
}
}
/// Get list of browsers supported on the current platform
pub fn get_supported_browsers(&self) -> Vec<String> {
let all_browsers = vec![
"firefox",
"firefox-developer",
"mullvad-browser",
"zen",
"brave",
"chromium",
"tor-browser",
];
all_browsers
.into_iter()
.filter(|browser| self.is_browser_supported(browser).unwrap_or(false))
.map(|s| s.to_string())
.collect()
}
/// 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)
@@ -58,7 +122,7 @@ impl BrowserVersionService {
.map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(), // Cache doesn't store dates
}
})
@@ -176,7 +240,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(),
}
}
@@ -197,7 +261,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(),
}
}
@@ -212,7 +276,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_alpha,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -233,7 +297,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -254,7 +318,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -281,7 +345,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Chromium versions are usually stable
is_prerelease: false, // Chromium usually stable releases
date: "".to_string(),
}
}
@@ -302,7 +366,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("rc"),
is_prerelease: false, // TOR Browser usually stable releases
date: "".to_string(),
}
}
@@ -355,151 +419,264 @@ impl BrowserVersionService {
browser: &str,
version: &str,
) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser {
"firefox" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"),
filename: format!("firefox-{version}.dmg"),
is_archive: true,
});
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
)
}
};
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=win64&lang=en-US"),
filename: format!("firefox-{version}.exe"),
is_archive: false,
});
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()),
};
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=linux64&lang=en-US"),
filename: format!("firefox-{version}.tar.bz2"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Firefox".into());
}
"firefox-developer" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"),
filename: format!("firefox-developer-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=win64&lang=en-US"),
filename: format!("firefox-developer-{version}.exe"),
is_archive: false,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=linux64&lang=en-US"),
filename: format!("firefox-developer-{version}.tar.bz2"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Firefox Developer".into());
}
"mullvad-browser" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-macos-{version}.dmg"
),
filename: format!("mullvad-browser-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-windows-{version}.exe"
),
filename: format!("mullvad-browser-{version}.exe"),
is_archive: false,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-linux-{version}.tar.xz"
),
filename: format!("mullvad-browser-{version}.tar.xz"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Mullvad Browser".into());
}
"zen" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.macos-universal.dmg"
),
filename: format!("zen-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.win.x64.zip"
),
filename: format!("zen-{version}.zip"),
is_archive: true,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.linux-x86_64.AppImage"
),
filename: format!("zen-{version}.AppImage"),
is_archive: false,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Zen Browser".into());
}
"brave" => {
// For Brave, we use a placeholder URL since we need to resolve the actual asset URL dynamically
// The actual URL will be resolved in the download service using the GitHub API
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/Brave-Browser-universal.dmg"
"https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US"
),
filename: format!("brave-{version}.dmg"),
is_archive: true,
filename,
is_archive,
})
}
"firefox-developer" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
.into(),
)
}
};
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"
),
filename,
is_archive,
})
}
"mullvad-browser" => {
// Mullvad Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("Mullvad Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64",
format!("mullvad-browser-windows-x86_64-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Linux".into());
}
(
"x86_64",
format!("mullvad-browser-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => (
"macos",
format!("mullvad-browser-macos-{version}.dmg"),
true,
),
_ => return Err(format!("Unsupported platform for Mullvad Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"zen" => {
let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false),
("windows", "arm64") => (
"zen.installer-arm64.exe",
format!("zen-{version}-arm64.exe"),
false,
),
("linux", "x64") => (
"zen.linux-x86_64.tar.xz",
format!("zen-{version}-x86_64.tar.xz"),
true,
),
("linux", "arm64") => (
"zen.linux-aarch64.tar.xz",
format!("zen-{version}-aarch64.tar.xz"),
true,
),
("macos", _) => (
"zen.macos-universal.dmg",
format!("zen-{version}.dmg"),
true,
),
_ => {
return Err(format!("Unsupported platform/architecture for Zen: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/{asset_name}"
),
filename,
is_archive,
})
}
"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),
("linux", "arm64") => (format!("brave-browser-{version}-linux-arm64.zip"), true),
("macos", _) => (format!("Brave-Browser-universal.dmg"), true),
_ => {
return Err(format!("Unsupported platform/architecture for Brave: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
),
filename,
is_archive,
})
}
"chromium" => {
let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" };
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()),
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => {
return Err(
format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into(),
)
}
};
let (archive_name, filename) = match os.as_str() {
"windows" => ("chrome-win.zip", format!("chromium-{version}-win.zip")),
"linux" => ("chrome-linux.zip", format!("chromium-{version}-linux.zip")),
"macos" => ("chrome-mac.zip", format!("chromium-{version}-mac.zip")),
_ => return Err(format!("Unsupported platform for Chromium: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/{version}/chrome-mac.zip"
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{platform_str}/{version}/{archive_name}"
),
filename: format!("chromium-{version}.zip"),
filename,
is_archive: true,
})
}
"tor-browser" => Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
),
filename: format!("tor-browser-{version}.dmg"),
is_archive: true,
}),
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("TOR Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64-portable",
format!("tor-browser-windows-x86_64-portable-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Linux".into());
}
(
"linux-x86_64",
format!("tor-browser-linux-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => ("macos", format!("tor-browser-macos-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for TOR Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
_ => Err(format!("Unsupported browser: {browser}").into()),
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
// Private helper methods for each browser type
async fn fetch_firefox_versions(
@@ -634,7 +811,7 @@ impl BrowserVersionService {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -692,7 +869,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -728,7 +904,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -770,7 +945,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -812,7 +987,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -825,21 +1000,31 @@ mod tests {
async fn setup_brave_mocks(server: &MockServer) {
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"tag_name": "v1.79.119",
"name": "Release v1.79.119 (Chromium 137.0.7151.68)",
"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",
"name": "brave-v1.79.119-universal.dmg",
"browser_download_url": "https://example.com/brave-1.79.119-universal.dmg",
"size": 200000000
},
{
"name": "brave-browser-1.79.119-linux-amd64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-amd64.zip",
"size": 150000000
},
{
"name": "brave-browser-1.79.119-linux-arm64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-arm64.zip",
"size": 145000000
}
]
},
{
"tag_name": "v1.81.8",
"name": "Brave Release 1.81.8",
"name": "Nightly v1.81.8",
"prerelease": false,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
@@ -854,7 +1039,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -873,7 +1058,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -921,7 +1105,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -932,7 +1115,6 @@ mod tests {
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)
@@ -943,7 +1125,6 @@ mod tests {
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)
@@ -954,7 +1135,6 @@ mod tests {
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)
@@ -966,7 +1146,7 @@ mod tests {
#[tokio::test]
async fn test_browser_version_service_creation() {
let _service = BrowserVersionService::new();
let _ = BrowserVersionService::new();
// Test passes if we can create the service without panicking
}
@@ -1304,7 +1484,7 @@ mod tests {
let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6")
.unwrap();
assert_eq!(mullvad_info.filename, "mullvad-browser-14.5a6.dmg");
assert_eq!(mullvad_info.filename, "mullvad-browser-macos-14.5a6.dmg");
assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive);
@@ -1316,20 +1496,20 @@ mod tests {
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
assert_eq!(tor_info.filename, "tor-browser-14.0.4.dmg");
assert_eq!(tor_info.filename, "tor-browser-macos-14.0.4.dmg");
assert!(tor_info.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive);
// Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
assert_eq!(chromium_info.filename, "chromium-1465660.zip");
assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip");
assert!(chromium_info.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive);
// Test Brave
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "brave-v1.81.9.dmg");
assert!(brave_info.url.contains("Brave-Browser"));
assert!(brave_info.url.contains("brave-placeholder"));
assert!(brave_info.is_archive);
// Test unsupported browser
+129 -3
View File
@@ -77,13 +77,139 @@ mod windows {
#[cfg(target_os = "linux")]
mod linux {
use std::process::Command;
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
let schemes = ["http", "https"];
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Query the current default handler for this scheme
let output = Command::new("xdg-mime")
.args(["query", "default", &mime_type])
.output()
.map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr));
}
let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Check if our app is the default handler
if current_handler != APP_DESKTOP_NAME {
return Ok(false);
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
// Check if the desktop file exists in common locations
if !check_desktop_file_exists() {
return Err(format!(
"Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.",
APP_DESKTOP_NAME
));
}
let schemes = ["http", "https"];
let mut all_succeeded = true;
let mut error_messages = Vec::new();
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Set our app as the default handler for this scheme
let output = Command::new("xdg-mime")
.args(["default", APP_DESKTOP_NAME, &mime_type])
.output()
.map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?;
if !output.status.success() {
all_succeeded = false;
let stderr = String::from_utf8_lossy(&output.stderr);
error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr));
}
}
if !all_succeeded {
return Err(format!(
"Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session",
error_messages.join("\n")
));
}
// Give the system a moment to process the changes
std::thread::sleep(std::time::Duration::from_millis(500));
// Verify the changes took effect
match is_default_browser() {
Ok(true) => Ok(()),
Ok(false) => {
// This is the common case where commands succeed but verification fails
Err(format!(
"The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.",
APP_DESKTOP_NAME
))
}
Err(e) => Err(format!(
"Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.",
e
))
}
}
fn is_xdg_mime_available() -> bool {
Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_desktop_file_exists() -> bool {
let desktop_locations = [
"~/.local/share/applications/",
"/usr/share/applications/",
"/usr/local/share/applications/",
"/var/lib/flatpak/exports/share/applications/",
"~/.local/share/flatpak/exports/share/applications/",
];
for location in &desktop_locations {
let path = if location.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
location.replace('~', &home)
} else {
continue;
}
} else {
location.to_string()
};
let full_path = format!("{}{}", path, APP_DESKTOP_NAME);
if std::path::Path::new(&full_path).exists() {
return true;
}
}
false
}
}
+233 -37
View File
@@ -51,7 +51,7 @@ impl Downloader {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
@@ -65,19 +65,20 @@ impl Downloader {
})
.ok_or(format!("Brave version {version} not found"))?;
// Find the universal macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No universal macOS DMG asset found for Brave version {version}"
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists
// For Zen, verify the asset exists and handle different naming patterns
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
@@ -88,16 +89,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Zen version {version} not found"))?;
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS universal asset found for Zen version {version}"
"No compatible asset found for Zen version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
@@ -111,16 +113,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?;
// Find the macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS asset found for Mullvad version {version}"
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
_ => {
// For other browsers, use the provided URL
@@ -129,6 +132,202 @@ impl Downloader {
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
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")
})
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Mullvad asset for the current platform and architecture
fn find_mullvad_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Mullvad asset naming patterns:
// Windows: mullvad-browser-windows-x86_64-VERSION.exe
// macOS: mullvad-browser-macos-VERSION.dmg
// Linux: mullvad-browser-x86_64-VERSION.tar.xz
let asset = match (os, arch) {
("windows", "x64") => assets.iter().find(|asset| {
asset.name.contains("windows")
&& asset.name.contains("x86_64")
&& asset.name.ends_with(".exe")
}),
("windows", "arm64") => {
// Mullvad doesn't support ARM64 on Windows
None
}
("macos", _) => assets
.iter()
.find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")),
("linux", "x64") => assets.iter().find(|asset| {
asset.name.contains("x86_64")
&& asset.name.ends_with(".tar.xz")
&& !asset.name.contains("windows")
}),
("linux", "arm64") => {
// Mullvad doesn't support ARM64 on Linux
None
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
@@ -170,7 +369,7 @@ impl Downloader {
let response = self
.client
.get(&download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -247,7 +446,7 @@ mod tests {
use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -290,7 +489,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -338,7 +537,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -386,7 +585,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -497,7 +696,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -547,7 +746,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -570,7 +769,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS universal asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -589,7 +788,6 @@ mod tests {
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content)
@@ -640,7 +838,6 @@ mod tests {
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -691,7 +888,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -714,7 +911,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -741,7 +938,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -780,7 +977,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content.clone())
+42
View File
@@ -175,6 +175,48 @@ impl DownloadedBrowsersRegistry {
}
Ok(())
}
/// Find and remove unused browser binaries that are not referenced by any active profiles
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let active_set: std::collections::HashSet<(String, String)> =
active_profiles.iter().cloned().collect();
let mut cleaned_up = Vec::new();
// Collect all downloaded browsers that are not in active profiles
let mut to_remove = Vec::new();
for (browser, versions) in &self.browsers {
for (version, info) in versions {
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
to_remove.push((browser.clone(), version.clone()));
}
}
}
// Remove unused binaries
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
} else {
cleaned_up.push(format!("{browser} {version}"));
}
}
Ok(cleaned_up)
}
/// Get all browsers and versions referenced by active profiles
pub fn get_active_browser_versions(
&self,
profiles: &[crate::browser_runner::BrowserProfile],
) -> Vec<(String, String)> {
profiles
.iter()
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
}
}
#[cfg(test)]
+705 -28
View File
@@ -34,18 +34,146 @@ impl Extractor {
};
let _ = app_handle.emit("download-progress", &progress);
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// Try to detect the actual file type by reading the file header
let actual_format = self.detect_file_format(archive_path)?;
match extension {
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
match actual_format.as_str() {
"dmg" => {
#[cfg(target_os = "macos")]
return self.extract_dmg(archive_path, dest_dir).await;
#[cfg(not(target_os = "macos"))]
return Err("DMG extraction is only supported on macOS".into());
}
"zip" => self.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
"tar.xz" => self.extract_tar_xz(archive_path, dest_dir).await,
"tar.bz2" => self.extract_tar_bz2(archive_path, dest_dir).await,
"tar.gz" => self.extract_tar_gz(archive_path, dest_dir).await,
"exe" => {
// For Windows EXE files, some may be self-extracting archives, others are installers
// For browsers like Firefox, TOR, they're typically installers that don't need extraction
self
.handle_exe_file(archive_path, dest_dir, browser_type)
.await
}
"deb" => {
#[cfg(target_os = "linux")]
return self.extract_deb(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("DEB extraction is only supported on Linux".into());
}
"appimage" => {
#[cfg(target_os = "linux")]
return self.handle_appimage(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("AppImage is only supported on Linux".into());
}
_ => {
Err(format!(
"Unsupported archive format: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format.",
archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"),
actual_format
).into())
}
}
}
/// Detect the actual file format by reading file headers
fn detect_file_format(
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use std::fs::File;
use std::io::Read;
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)?;
// Check magic numbers for different file types
match &buffer[0..4] {
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
return Ok("zip".to_string())
}
[0x7F, 0x45, 0x4C, 0x46] => return Ok("appimage".to_string()), // ELF header (AppImage)
[0x4D, 0x5A, _, _] => return Ok("exe".to_string()), // PE header (Windows EXE)
_ => {}
}
// Check for XZ compressed files
if buffer[0..6] == [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] {
return Ok("tar.xz".to_string());
}
// Check for Bzip2 compressed files
if buffer[0..3] == [0x42, 0x5A, 0x68] {
return Ok("tar.bz2".to_string());
}
// Check for Gzip compressed files
if buffer[0..3] == [0x1F, 0x8B, 0x08] {
return Ok("tar.gz".to_string());
}
// Check for DEB files
if buffer[0..8] == [0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A] {
return Ok("deb".to_string());
}
// Fallback to file extension
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
match ext.to_lowercase().as_str() {
"dmg" => Ok("dmg".to_string()),
"zip" => Ok("zip".to_string()),
"xz" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.xz")
{
Ok("tar.xz".to_string())
} else {
Ok("xz".to_string())
}
}
"bz2" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.bz2")
{
Ok("tar.bz2".to_string())
} else {
Ok("bz2".to_string())
}
}
"gz" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.gz")
{
Ok("tar.gz".to_string())
} else {
Ok("gz".to_string())
}
}
"exe" => Ok("exe".to_string()),
"deb" => Ok("deb".to_string()),
"appimage" => Ok("appimage".to_string()),
_ => Ok("unknown".to_string()),
}
} else {
Ok("unknown".to_string())
}
}
#[cfg(target_os = "macos")]
pub async fn extract_dmg(
&self,
dmg_path: &Path,
@@ -154,7 +282,56 @@ impl Extractor {
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command to extract
// Platform-specific ZIP extraction
#[cfg(target_os = "windows")]
{
self.extract_zip_windows(zip_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
self.extract_zip_unix(zip_path, dest_dir).await
}
}
#[cfg(target_os = "windows")]
async fn extract_zip_windows(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use PowerShell's Expand-Archive on Windows
let output = Command::new("powershell")
.args([
"-Command",
&format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
zip_path.display(),
dest_dir.display()
),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract zip with PowerShell: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
self.find_extracted_executable(dest_dir).await
}
#[cfg(not(target_os = "windows"))]
async fn extract_zip_unix(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command on Unix-like systems
let output = Command::new("unzip")
.args([
"-q", // quiet
@@ -174,16 +351,269 @@ impl Extractor {
);
}
// Find the extracted .app directory or Chromium.app specifically
let mut app_path: Option<PathBuf> = None;
self.find_extracted_executable(dest_dir).await
}
pub async fn extract_tar_xz(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.xz: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
pub async fn extract_tar_bz2(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xjf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.bz2: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
pub async fn extract_tar_gz(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xzf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.gz: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
#[cfg(target_os = "linux")]
pub async fn extract_deb(
&self,
deb_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Extract DEB package using dpkg-deb
let output = Command::new("dpkg-deb")
.args(["-x", deb_path.to_str().unwrap(), dest_dir.to_str().unwrap()])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract DEB: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly
self.set_executable_permissions(&executable_path).await?;
Ok(executable_path)
}
#[cfg(target_os = "linux")]
pub async fn handle_appimage(
&self,
appimage_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// For AppImages, we typically just copy them and make sure they're executable
let dest_file = dest_dir.join(
appimage_path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("app.AppImage")),
);
// Copy the AppImage to destination
fs::copy(appimage_path, &dest_file)?;
// Set executable permissions
self.set_executable_permissions(&dest_file).await?;
Ok(dest_file)
}
pub async fn handle_exe_file(
&self,
exe_path: &Path,
dest_dir: &Path,
browser_type: BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Zen => {
// Zen installer EXE needs to be run to install
#[cfg(target_os = "windows")]
{
self.install_zen_windows(exe_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
Err("Zen EXE installation is only supported on Windows".into())
}
}
_ => {
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
}
}
#[cfg(target_os = "windows")]
async fn install_zen_windows(
&self,
installer_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For Zen installer, we need to run it silently
// This is a simplified approach - in practice, you might need more sophisticated installer handling
let output = Command::new(installer_path)
.args(["/S", &format!("/D={}", dest_dir.display())])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
}
async fn find_extracted_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Platform-specific executable finding logic
#[cfg(target_os = "macos")]
{
self.find_macos_app(dest_dir).await
}
#[cfg(target_os = "windows")]
{
self.find_windows_executable(dest_dir).await
}
#[cfg(target_os = "linux")]
{
self.find_linux_executable(dest_dir).await
}
}
#[cfg(target_os = "macos")]
async fn find_macos_app(
&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") {
app_path = Some(path);
break;
return Ok(path);
}
// For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() {
@@ -194,33 +624,280 @@ impl Extractor {
// 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)?;
app_path = Some(target_path);
// Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path);
break;
return Ok(target_path);
}
}
if app_path.is_some() {
break;
}
}
}
}
}
let app_path = app_path.ok_or("No .app found after extraction")?;
Err("No .app found after extraction".into())
}
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
#[cfg(target_os = "windows")]
async fn find_windows_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Look for .exe files, preferring main browser executables
let exe_names = [
"chrome.exe",
"firefox.exe",
"zen.exe",
"brave.exe",
"tor.exe",
];
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
for exe_name in &exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() {
return Ok(exe_path);
}
}
Ok(app_path)
// If no specific executable found, look for any .exe file
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 == "exe") {
return Ok(path);
}
// Check subdirectories
if path.is_dir() {
if let Ok(sub_result) = self.find_windows_executable(&path).await {
return Ok(sub_result);
}
}
}
}
Err("No executable found after extraction".into())
}
#[cfg(target_os = "linux")]
async fn find_linux_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Enhanced list of common browser executable names with better pattern matching
let exe_names = [
// Firefox variants
"firefox",
"firefox-bin",
"firefox-esr",
"firefox-trunk",
// Chrome/Chromium variants
"chrome",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium-bin",
// Zen Browser
"zen",
"zen-browser",
"zen-bin",
// Brave variants
"brave",
"brave-browser",
"brave-browser-stable",
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Tor Browser variants
"tor-browser",
"torbrowser-launcher",
"tor-browser_en-US",
"start-tor-browser",
"Browser/start-tor-browser",
// Mullvad Browser
"mullvad-browser",
"mullvad-browser-bin",
// AppImage pattern (will be handled specially)
"*.AppImage",
];
// First, try direct lookup in the main directory
for exe_name in &exe_names {
if exe_name.contains('*') {
// Handle glob patterns like *.AppImage
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
return Ok(path);
}
}
}
}
} else {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
return Ok(exe_path);
}
}
}
// Enhanced list of common Linux subdirectories to search
let subdirs = [
// Standard Unix directories
"bin",
"usr/bin",
"usr/local/bin",
"opt",
"sbin",
"usr/sbin",
// Browser-specific directories
"firefox",
"chrome",
"chromium",
"brave",
"zen",
"tor-browser",
"mullvad-browser",
// Common extraction patterns
".",
"./",
// Package-specific extraction patterns
"firefox",
"mullvad-browser",
"tor-browser_en-US",
"Browser",
"browser",
// Nested patterns for different distro packaging
"opt/google/chrome",
"opt/brave.com/brave",
"opt/mullvad-browser",
"usr/lib/firefox",
"usr/lib/chromium",
"usr/share/applications",
// AppImage mount patterns
"usr/bin",
"AppRun",
];
// Search in subdirectories with better depth handling
for subdir in &subdirs {
let subdir_path = dest_dir.join(subdir);
if subdir_path.exists() && subdir_path.is_dir() {
for exe_name in &exe_names {
if exe_name.contains('*') {
// Handle glob patterns for AppImages
if let Ok(entries) = fs::read_dir(&subdir_path) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
return Ok(path);
}
}
}
}
} else {
let exe_path = subdir_path.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
return Ok(exe_path);
}
}
}
}
}
// Last resort: enhanced recursive search for any executable file
self.find_any_executable_recursive(dest_dir, 0).await
}
#[cfg(target_os = "linux")]
fn is_executable(&self, path: &Path) -> bool {
if let Ok(metadata) = path.metadata() {
use std::os::unix::fs::PermissionsExt;
return metadata.permissions().mode() & 0o111 != 0;
}
false
}
/// Set executable permissions on Linux for extracted binaries
#[cfg(target_os = "linux")]
async fn set_executable_permissions(
&self,
path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::os::unix::fs::PermissionsExt;
if path.exists() {
let mut permissions = path.metadata()?.permissions();
// Set executable permissions for owner, group, and others if they have read permission
let current_mode = permissions.mode();
let new_mode = current_mode | 0o111; // Add execute permission
permissions.set_mode(new_mode);
std::fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
async fn set_executable_permissions(
&self,
_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn find_any_executable_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Limit recursion depth to avoid infinite loops
if depth > 5 {
return Err("Maximum search depth reached".into());
}
if let Ok(entries) = fs::read_dir(dir) {
let mut directories = Vec::new();
// First pass: look for executable files
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && self.is_executable(&path) {
// Prefer files with browser-like names
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
let name_lower = file_name.to_lowercase();
if name_lower.contains("firefox")
|| name_lower.contains("chrome")
|| name_lower.contains("brave")
|| name_lower.contains("zen")
|| name_lower.contains("tor")
|| name_lower.contains("mullvad")
|| file_name.ends_with(".AppImage")
{
return Ok(path);
}
}
} else if path.is_dir() {
directories.push(path);
}
}
// Second pass: recursively search directories
for dir_path in directories {
if let Ok(result) = Box::pin(self.find_any_executable_recursive(&dir_path, depth + 1)).await
{
return Ok(result);
}
}
}
Err("No executable found".into())
}
}
@@ -232,13 +909,13 @@ mod tests {
#[test]
fn test_extractor_creation() {
let _extractor = Extractor::new();
let _ = Extractor::new();
// Just verify we can create an extractor instance
}
#[test]
fn test_unsupported_archive_format() {
let _extractor = Extractor::new();
let _ = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap();
+181 -93
View File
@@ -1,6 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -19,22 +18,22 @@ mod downloaded_browsers;
mod extraction;
mod proxy_manager;
mod settings_manager;
mod theme_detector;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed,
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers,
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles,
rename_profile, should_update_browser_cache, update_profile_proxy, update_profile_version,
check_browser_exists, check_browser_status, cleanup_unused_binaries, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions_cached_first,
fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions, get_supported_browsers, is_browser_supported_on_platform,
kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile,
update_profile_proxy, update_profile_version,
};
use settings_manager::{
disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings,
clear_all_version_cache, get_app_settings, get_table_sorting_settings, save_app_settings,
save_table_sorting_settings, should_show_settings_on_startup,
};
@@ -43,21 +42,21 @@ use default_browser::{
};
use version_updater::{
check_version_update_needed, force_version_update_check, get_version_update_status,
get_version_updater, trigger_manual_version_update,
get_version_update_status, get_version_updater, trigger_manual_version_update,
};
use auto_updater::{
check_for_browser_updates, complete_browser_update, complete_browser_update_with_auto_update,
dismiss_update_notification, is_auto_update_download, is_browser_disabled_for_update,
mark_auto_update_download, remove_auto_update_download, start_browser_update,
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
remove_auto_update_download,
};
use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
use theme_detector::get_system_theme;
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
@@ -103,13 +102,6 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
fn greet() -> String {
let now = SystemTime::now();
let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
format!("Hello world from Rust! Current epoch: {epoch_ms}")
}
#[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {url}");
@@ -169,61 +161,6 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
Ok(false)
}
#[tauri::command]
async fn set_window_background_color(
app_handle: tauri::AppHandle,
is_dark_mode: bool,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
if let Some(window) = app_handle.get_webview_window("main") {
use objc2::rc::Retained;
use objc2_app_kit::{NSColor, NSWindow};
let ns_window: Retained<NSWindow> =
unsafe { Retained::retain(window.ns_window().unwrap().cast()).unwrap() };
let bg_color = if is_dark_mode {
// Dark mode - pure black background
unsafe { NSColor::colorWithRed_green_blue_alpha(0.0, 0.0, 0.0, 1.0) }
} else {
// Light mode - pure white background
unsafe { NSColor::colorWithRed_green_blue_alpha(1.0, 1.0, 1.0, 1.0) }
};
// Ensure this runs on the main thread for immediate visual update
unsafe {
// Set the window background color
ns_window.setBackgroundColor(Some(&bg_color));
// Force immediate visual updates using multiple refresh methods
ns_window.invalidateShadow();
ns_window.display();
// Ensure the window content is redrawn
if let Some(content_view) = ns_window.contentView() {
content_view.setNeedsDisplay(true);
content_view.displayIfNeeded();
}
// Trigger a window update
ns_window.update();
}
// Also emit an event to the frontend to ensure synchronization
let _ = app_handle.emit("window-background-updated", is_dark_mode);
}
}
#[cfg(not(target_os = "macos"))]
{
// For non-macOS platforms, we can't change the native window background
let _ = (app_handle, is_dark_mode); // Suppress unused variable warnings
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -233,12 +170,14 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// Set transparent titlebar for macOS
@@ -328,25 +267,19 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
delete_profile,
is_browser_downloaded,
check_browser_exists,
cleanup_unused_binaries,
create_browser_profile_new,
create_browser_profile,
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count,
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions,
get_saved_mullvad_releases,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -355,22 +288,17 @@ pub fn run() {
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
clear_all_version_cache,
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
check_for_browser_updates,
start_browser_update,
complete_browser_update,
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
@@ -380,9 +308,169 @@ pub fn run() {
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
get_app_version_info,
set_window_background_color,
get_system_theme,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use std::fs;
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
}
#[test]
fn test_unused_tauri_commands_detailed() {
check_unused_commands(true); // Run in verbose mode for development
}
fn check_unused_commands(verbose: bool) {
// Extract command names from the generate_handler! macro in this file
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
let commands = extract_tauri_commands(&lib_rs_content);
// Get all frontend files
let frontend_files = get_frontend_files("../src");
// Check which commands are actually used
let mut unused_commands = Vec::new();
let mut used_commands = Vec::new();
for command in &commands {
let mut is_used = false;
for file_content in &frontend_files {
// More comprehensive search for command usage
if is_command_used(file_content, command) {
is_used = true;
break;
}
}
if is_used {
used_commands.push(command.clone());
if verbose {
println!("{command}");
}
} else {
unused_commands.push(command.clone());
if verbose {
println!("{command} (UNUSED)");
}
}
}
if verbose {
println!("\n📊 Summary:");
println!(" ✅ Used commands: {}", used_commands.len());
println!(" ❌ Unused commands: {}", unused_commands.len());
}
if !unused_commands.is_empty() {
let message = format!(
"Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.",
unused_commands.len(),
unused_commands.join(", ")
);
if verbose {
println!("\n🚨 {message}");
} else {
panic!("{}", message);
}
} else if verbose {
println!("\n🎉 All exported commands are being used!");
} else {
println!(
"✅ All {} exported Tauri commands are being used in the frontend",
commands.len()
);
}
}
fn is_command_used(content: &str, command: &str) -> bool {
// Check various patterns for invoke usage
let patterns = vec![
format!("invoke<{}>(\"{}\"", "", command), // invoke<Type>("command"
format!("invoke(\"{}\"", command), // invoke("command"
format!("invoke<{}>(\"{}\",", "", command), // invoke<Type>("command",
format!("invoke(\"{}\",", command), // invoke("command",
format!("\"{}\"", command), // Just the command name in quotes
];
for pattern in patterns {
if content.contains(&pattern) {
return true;
}
}
// Also check for the command name appearing after "invoke" within a reasonable distance
if let Some(invoke_pos) = content.find("invoke") {
let after_invoke = &content[invoke_pos..];
if let Some(cmd_pos) = after_invoke.find(&format!("\"{command}\"")) {
// If the command appears within 100 characters of "invoke", consider it used
if cmd_pos < 100 {
return true;
}
}
}
false
}
fn extract_tauri_commands(content: &str) -> Vec<String> {
let mut commands = Vec::new();
// Find the generate_handler! macro
if let Some(start) = content.find("tauri::generate_handler![") {
if let Some(end) = content[start..].find("])") {
let handler_content = &content[start + 25..start + end]; // Skip "tauri::generate_handler!["
// Extract command names
for line in handler_content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with("//") {
// Remove trailing comma and whitespace
let command = line.trim_end_matches(',').trim();
if !command.is_empty() {
commands.push(command.to_string());
}
}
}
}
}
commands
}
fn get_frontend_files(src_dir: &str) -> Vec<String> {
let mut files_content = Vec::new();
if let Ok(entries) = fs::read_dir(src_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Recursively read subdirectories
let subdir_files = get_frontend_files(&path.to_string_lossy());
files_content.extend(subdir_files);
} else if let Some(extension) = path.extension() {
if matches!(
extension.to_str(),
Some("ts") | Some("tsx") | Some("js") | Some("jsx")
) {
if let Ok(content) = fs::read_to_string(&path) {
files_content.push(content);
}
}
}
}
}
files_content
}
}
+17 -15
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -28,6 +30,8 @@ pub struct AppSettings {
pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool,
#[serde(default = "default_auto_delete_unused_binaries")]
pub auto_delete_unused_binaries: bool,
}
fn default_show_settings_on_startup() -> bool {
@@ -42,6 +46,10 @@ fn default_auto_updates_enabled() -> bool {
true
}
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -49,6 +57,7 @@ impl Default for AppSettings {
show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(),
auto_delete_unused_binaries: default_auto_delete_unused_binaries(),
}
}
}
@@ -163,13 +172,6 @@ impl SettingsManager {
// 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
}
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
}
#[tauri::command]
@@ -196,14 +198,6 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
.map_err(|e| format!("Failed to check prompt setting: {e}"))
}
#[tauri::command]
pub async fn disable_default_browser_prompt() -> Result<(), String> {
let manager = SettingsManager::new();
manager
.disable_default_browser_prompt()
.map_err(|e| format!("Failed to disable prompt: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new();
@@ -219,3 +213,11 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
}
#[tauri::command]
pub async fn clear_all_version_cache() -> Result<(), String> {
let api_client = ApiClient::new();
api_client
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))
}
+539
View File
@@ -0,0 +1,539 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTheme {
pub theme: String, // "light", "dark", or "unknown"
}
pub struct ThemeDetector;
impl ThemeDetector {
pub fn new() -> Self {
Self
}
/// Detect the system theme preference
pub fn detect_system_theme(&self) -> SystemTheme {
#[cfg(target_os = "linux")]
return linux::detect_system_theme();
#[cfg(target_os = "macos")]
return macos::detect_system_theme();
#[cfg(target_os = "windows")]
return windows::detect_system_theme();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
return SystemTheme {
theme: "unknown".to_string(),
};
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Try multiple methods in order of preference
// 1. Try GNOME/GTK settings via gsettings
if let Ok(theme) = detect_gnome_theme() {
return SystemTheme { theme };
}
// 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6
if let Ok(theme) = detect_kde_theme() {
return SystemTheme { theme };
}
// 3. Try XFCE settings via xfconf-query
if let Ok(theme) = detect_xfce_theme() {
return SystemTheme { theme };
}
// 4. Try looking at current GTK theme name
if let Ok(theme) = detect_gtk_theme() {
return SystemTheme { theme };
}
// 5. Try dconf directly (fallback for GNOME-based systems)
if let Ok(theme) = detect_dconf_theme() {
return SystemTheme { theme };
}
// 6. Try environment variables
if let Ok(theme) = detect_env_theme() {
return SystemTheme { theme };
}
// 7. Try freedesktop portal
if let Ok(theme) = detect_portal_theme() {
return SystemTheme { theme };
}
// 8. Try looking at system color scheme files
if let Ok(theme) = detect_system_files_theme() {
return SystemTheme { theme };
}
// Fallback to unknown
SystemTheme {
theme: "unknown".to_string(),
}
}
fn detect_gnome_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if gsettings is available
if !is_command_available("gsettings") {
return Err("gsettings not available".into());
}
// Try GNOME color scheme first (modern way)
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Fallback to GTK theme name detection
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect GNOME theme".into())
}
fn detect_kde_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try KDE Plasma 6 first
if is_command_available("kreadconfig6") {
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
// Try color scheme as well
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"General",
"--key",
"ColorScheme",
])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if scheme.contains("dark") || scheme.contains("breezedark") {
return Ok("dark".to_string());
} else if scheme.contains("light") || scheme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
// Try KDE Plasma 5 as fallback
if is_command_available("kreadconfig5") {
if let Ok(output) = Command::new("kreadconfig5")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
Err("Could not detect KDE theme".into())
}
fn detect_xfce_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("xfconf-query") {
return Err("xfconf-query not available".into());
}
// Check XFCE theme
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xsettings", "-p", "/Net/ThemeName"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
// Check XFCE window manager theme as backup
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xfwm4", "-p", "/general/theme"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
Err("Could not detect XFCE theme".into())
}
fn detect_gtk_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try to read GTK3 settings file
if let Ok(home) = std::env::var("HOME") {
let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini");
if gtk3_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk3_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
// Try GTK4 settings
let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini");
if gtk4_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk4_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
}
Err("Could not detect GTK theme".into())
}
fn detect_dconf_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("dconf") {
return Err("dconf not available".into());
}
// Try reading color scheme directly from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Try reading GTK theme from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect dconf theme".into())
}
fn detect_env_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check common environment variables
if let Ok(theme) = std::env::var("GTK_THEME") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
Err("Could not detect theme from environment".into())
}
fn detect_portal_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("busctl") {
return Err("busctl not available".into());
}
// Try to query the color scheme via org.freedesktop.portal.Settings
if let Ok(output) = Command::new("busctl")
.args([
"--user",
"call",
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
"ss",
"org.freedesktop.appearance",
"color-scheme",
])
.output()
{
if output.status.success() {
let response = String::from_utf8_lossy(&output.stdout);
// Parse DBus response - look for preference values
if response.contains(" 1 ") {
return Ok("dark".to_string());
} else if response.contains(" 2 ") {
return Ok("light".to_string());
}
}
}
Err("Could not detect portal theme".into())
}
fn detect_system_files_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if we're in a dark terminal (heuristic)
if let Ok(term) = std::env::var("TERM") {
let term_lower = term.to_lowercase();
if term_lower.contains("dark") || term_lower.contains("night") {
return Ok("dark".to_string());
}
}
// Check if we can determine from desktop session
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop_lower = desktop.to_lowercase();
// Some desktops default to dark
if desktop_lower.contains("i3") || desktop_lower.contains("sway") {
// Window managers often use dark themes by default
return Ok("dark".to_string());
}
}
Err("Could not detect theme from system files".into())
}
fn is_command_available(command: &str) -> bool {
Command::new("which")
.arg(command)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// macOS theme detection using osascript
if let Ok(output) = Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to tell appearance preferences to get dark mode",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).to_string();
let result = result.trim();
match result {
"true" => {
return SystemTheme {
theme: "dark".to_string(),
}
}
"false" => {
return SystemTheme {
theme: "light".to_string(),
}
}
_ => {}
}
}
}
// Fallback method using defaults
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
{
if output.status.success() {
let style = String::from_utf8_lossy(&output.stdout).to_string();
let style = style.trim();
if style.to_lowercase() == "dark" {
return SystemTheme {
theme: "dark".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Windows theme detection via registry
// This is a simplified implementation - you might want to use winreg crate for better registry access
if let Ok(output) = Command::new("reg")
.args([
"query",
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
"/v",
"AppsUseLightTheme",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout);
if result.contains("0x0") {
return SystemTheme {
theme: "dark".to_string(),
};
} else if result.contains("0x1") {
return SystemTheme {
theme: "light".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
// Command to expose this functionality to the frontend
#[tauri::command]
pub fn get_system_theme() -> SystemTheme {
let detector = ThemeDetector::new();
detector.detect_system_theme()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_detector_creation() {
let detector = ThemeDetector::new();
let theme = detector.detect_system_theme();
// Should return a valid theme string
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
#[test]
fn test_get_system_theme_command() {
let theme = get_system_theme();
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
}
-16
View File
@@ -448,22 +448,6 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
Ok((last_update, time_until_next))
}
#[tauri::command]
pub async fn check_version_update_needed() -> Result<bool, String> {
Ok(VersionUpdater::should_run_background_update())
}
#[tauri::command]
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
match updater_guard.check_and_run_startup_update().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Failed to run version update check: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;