mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-25 05:16:18 +02:00
366 lines
9.9 KiB
Rust
366 lines
9.9 KiB
Rust
use std::fs::{self, create_dir_all};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use tauri::Emitter;
|
|
|
|
use crate::browser::BrowserType;
|
|
use crate::download::DownloadProgress;
|
|
|
|
pub struct Extractor;
|
|
|
|
impl Extractor {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
pub async fn extract_browser(
|
|
&self,
|
|
app_handle: &tauri::AppHandle,
|
|
browser_type: BrowserType,
|
|
version: &str,
|
|
archive_path: &Path,
|
|
dest_dir: &Path,
|
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
|
// Emit extraction start progress
|
|
let progress = DownloadProgress {
|
|
browser: browser_type.as_str().to_string(),
|
|
version: version.to_string(),
|
|
downloaded_bytes: 0,
|
|
total_bytes: None,
|
|
percentage: 0.0,
|
|
speed_bytes_per_sec: 0.0,
|
|
eta_seconds: None,
|
|
stage: "extracting".to_string(),
|
|
};
|
|
let _ = app_handle.emit("download-progress", &progress);
|
|
|
|
let extension = archive_path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.unwrap_or("");
|
|
|
|
match extension {
|
|
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
|
|
"zip" => self.extract_zip(archive_path, dest_dir).await,
|
|
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
|
}
|
|
}
|
|
|
|
pub async fn extract_dmg(
|
|
&self,
|
|
dmg_path: &Path,
|
|
dest_dir: &Path,
|
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
|
// Create a temporary mount point
|
|
let mount_point = std::env::temp_dir().join(format!(
|
|
"donut_mount_{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
));
|
|
create_dir_all(&mount_point)?;
|
|
|
|
// Mount the DMG
|
|
let output = Command::new("hdiutil")
|
|
.args([
|
|
"attach",
|
|
"-nobrowse",
|
|
"-mountpoint",
|
|
mount_point.to_str().unwrap(),
|
|
dmg_path.to_str().unwrap(),
|
|
])
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Err(
|
|
format!(
|
|
"Failed to mount DMG: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Find the .app directory in the mount point
|
|
let app_entry = fs::read_dir(&mount_point)?
|
|
.filter_map(Result::ok)
|
|
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
|
|
.ok_or("No .app found in DMG")?;
|
|
|
|
// Copy the .app to the destination
|
|
let app_path = dest_dir.join(app_entry.file_name());
|
|
|
|
let output = Command::new("cp")
|
|
.args([
|
|
"-R",
|
|
app_entry.path().to_str().unwrap(),
|
|
app_path.to_str().unwrap(),
|
|
])
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Err(
|
|
format!(
|
|
"Failed to copy app: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Remove quarantine attributes
|
|
let _ = Command::new("xattr")
|
|
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
|
.output();
|
|
|
|
let _ = Command::new("xattr")
|
|
.args(["-cr", app_path.to_str().unwrap()])
|
|
.output();
|
|
|
|
// Try to unmount the DMG with retries
|
|
let mut retry_count = 0;
|
|
let max_retries = 3;
|
|
let mut unmounted = false;
|
|
|
|
while retry_count < max_retries && !unmounted {
|
|
// Wait a bit before trying to unmount
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
|
|
let output = Command::new("hdiutil")
|
|
.args(["detach", mount_point.to_str().unwrap()])
|
|
.output()?;
|
|
|
|
if output.status.success() {
|
|
unmounted = true;
|
|
} else if retry_count == max_retries - 1 {
|
|
// Force unmount on last retry
|
|
let _ = Command::new("hdiutil")
|
|
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
|
.output();
|
|
unmounted = true; // Consider it unmounted even if force fails
|
|
}
|
|
retry_count += 1;
|
|
}
|
|
|
|
// Clean up mount point directory
|
|
let _ = fs::remove_dir_all(&mount_point);
|
|
|
|
Ok(app_path)
|
|
}
|
|
|
|
pub async fn extract_zip(
|
|
&self,
|
|
zip_path: &Path,
|
|
dest_dir: &Path,
|
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
|
// Use unzip command to extract
|
|
let output = Command::new("unzip")
|
|
.args([
|
|
"-q", // quiet
|
|
zip_path.to_str().unwrap(),
|
|
"-d",
|
|
dest_dir.to_str().unwrap(),
|
|
])
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Err(
|
|
format!(
|
|
"Failed to extract zip: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Find the extracted .app directory or Chromium.app specifically
|
|
let mut app_path: Option<PathBuf> = None;
|
|
|
|
// 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;
|
|
}
|
|
// For Chromium, check subdirectories (chrome-mac folder)
|
|
if path.is_dir() {
|
|
if let Ok(sub_entries) = fs::read_dir(&path) {
|
|
for sub_entry in sub_entries.flatten() {
|
|
let sub_path = sub_entry.path();
|
|
if sub_path.extension().is_some_and(|ext| ext == "app") {
|
|
// Move the app to the root destination directory
|
|
let target_path = dest_dir.join(sub_path.file_name().unwrap());
|
|
fs::rename(&sub_path, &target_path)?;
|
|
app_path = Some(target_path);
|
|
|
|
// Clean up the now-empty subdirectory
|
|
let _ = fs::remove_dir_all(&path);
|
|
break;
|
|
}
|
|
}
|
|
if app_path.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let app_path = app_path.ok_or("No .app found after extraction")?;
|
|
|
|
// Remove quarantine attributes
|
|
let _ = Command::new("xattr")
|
|
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
|
.output();
|
|
|
|
let _ = Command::new("xattr")
|
|
.args(["-cr", app_path.to_str().unwrap()])
|
|
.output();
|
|
|
|
Ok(app_path)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs::File;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_extractor_creation() {
|
|
let _extractor = Extractor::new();
|
|
// Just verify we can create an extractor instance
|
|
}
|
|
|
|
#[test]
|
|
fn test_unsupported_archive_format() {
|
|
let _extractor = Extractor::new();
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let fake_archive = temp_dir.path().join("test.rar");
|
|
File::create(&fake_archive).unwrap();
|
|
|
|
// Create a mock app handle (this won't work in real tests without Tauri runtime)
|
|
// For now, we'll just test the logic without the actual extraction
|
|
|
|
// Test that unsupported formats return an error
|
|
let extension = fake_archive
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.unwrap_or("");
|
|
|
|
assert_eq!(extension, "rar");
|
|
// We know this would fail with "Unsupported archive format: rar"
|
|
}
|
|
|
|
#[test]
|
|
fn test_dmg_path_validation() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let dmg_path = temp_dir.path().join("test.dmg");
|
|
|
|
// Test that we can identify DMG files correctly
|
|
let extension = dmg_path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.unwrap_or("");
|
|
|
|
assert_eq!(extension, "dmg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_zip_path_validation() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let zip_path = temp_dir.path().join("test.zip");
|
|
|
|
// Test that we can identify ZIP files correctly
|
|
let extension = zip_path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.unwrap_or("");
|
|
|
|
assert_eq!(extension, "zip");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mount_point_generation() {
|
|
// Test that mount point generation creates unique paths
|
|
let mount_point1 = std::env::temp_dir().join(format!(
|
|
"donut_mount_{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
));
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
|
|
let mount_point2 = std::env::temp_dir().join(format!(
|
|
"donut_mount_{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
));
|
|
|
|
// They should be different (or at least have the potential to be)
|
|
assert!(mount_point1.to_string_lossy().contains("donut_mount_"));
|
|
assert!(mount_point2.to_string_lossy().contains("donut_mount_"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_app_path_detection() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
// Create a fake .app directory
|
|
let app_dir = temp_dir.path().join("TestApp.app");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
|
|
// Test finding .app directories
|
|
let entries: Vec<_> = fs::read_dir(temp_dir.path())
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
|
|
.collect();
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].file_name(), "TestApp.app");
|
|
}
|
|
|
|
#[test]
|
|
fn test_nested_app_detection() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
// Create a nested structure like Chromium
|
|
let chrome_dir = temp_dir.path().join("chrome-mac");
|
|
std::fs::create_dir_all(&chrome_dir).unwrap();
|
|
|
|
let app_dir = chrome_dir.join("Chromium.app");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
|
|
// Test finding nested .app directories
|
|
let mut found_app = false;
|
|
|
|
if let Ok(entries) = fs::read_dir(temp_dir.path()) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Ok(sub_entries) = fs::read_dir(&path) {
|
|
for sub_entry in sub_entries.flatten() {
|
|
let sub_path = sub_entry.path();
|
|
if sub_path.extension().is_some_and(|ext| ext == "app") {
|
|
found_app = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(found_app);
|
|
}
|
|
}
|