mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-18 10:27:29 +02:00
2087 lines
66 KiB
Rust
2087 lines
66 KiB
Rust
use std::fs::{self, File};
|
||
use std::io::{self, BufReader, Read};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use crate::browser::BrowserType;
|
||
use crate::downloader::DownloadProgress;
|
||
use crate::events;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
use tokio::process::Command;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
use std::fs::create_dir_all;
|
||
|
||
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
|
||
///
|
||
/// Uses `getxattr` with a null buffer to query the attribute size only —
|
||
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
|
||
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
|
||
/// fires the prompt on the modify-class syscall (`removexattr`) even when
|
||
/// the operation is a no-op, so skipping the call entirely when the
|
||
/// attribute is absent is the only way to stay quiet.
|
||
#[cfg(target_os = "macos")]
|
||
fn has_quarantine_attr(path: &Path) -> bool {
|
||
use std::ffi::CString;
|
||
use std::os::unix::ffi::OsStrExt;
|
||
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
|
||
return false;
|
||
};
|
||
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
|
||
return false;
|
||
};
|
||
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
|
||
// makes it a pure read-only size query.
|
||
let result = unsafe {
|
||
libc::getxattr(
|
||
path_c.as_ptr(),
|
||
attr_c.as_ptr(),
|
||
std::ptr::null_mut(),
|
||
0,
|
||
0,
|
||
0,
|
||
)
|
||
};
|
||
result >= 0
|
||
}
|
||
|
||
/// Best-effort recursive size of a file tree. Uses `symlink_metadata` so
|
||
/// symlinks inside .app bundles are not followed (`cp -R` copies them as
|
||
/// links, so following them would overcount and could loop).
|
||
#[cfg(target_os = "macos")]
|
||
fn dir_size(path: &Path) -> u64 {
|
||
let Ok(meta) = fs::symlink_metadata(path) else {
|
||
return 0;
|
||
};
|
||
if meta.is_file() {
|
||
return meta.len();
|
||
}
|
||
if !meta.is_dir() {
|
||
return 0;
|
||
}
|
||
let Ok(entries) = fs::read_dir(path) else {
|
||
return 0;
|
||
};
|
||
entries.flatten().map(|entry| dir_size(&entry.path())).sum()
|
||
}
|
||
|
||
const PROGRESS_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
||
|
||
/// Emits throttled "extracting" progress on the `download-progress` channel so
|
||
/// the UI can render a moving bar during long extractions.
|
||
pub struct ExtractionReporter {
|
||
browser: String,
|
||
version: String,
|
||
last_emit: std::sync::Mutex<std::time::Instant>,
|
||
}
|
||
|
||
impl ExtractionReporter {
|
||
pub fn new(browser: String, version: String) -> Self {
|
||
// Backdate so the first report goes out immediately.
|
||
let backdated = std::time::Instant::now()
|
||
.checked_sub(PROGRESS_REPORT_INTERVAL)
|
||
.unwrap_or_else(std::time::Instant::now);
|
||
Self {
|
||
browser,
|
||
version,
|
||
last_emit: std::sync::Mutex::new(backdated),
|
||
}
|
||
}
|
||
|
||
/// Report byte-level progress where bytes map linearly onto the whole job.
|
||
pub fn report_bytes(&self, done: u64, total: u64) {
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
let done = done.min(total);
|
||
self.report((done as f64 / total as f64) * 100.0, done, Some(total));
|
||
}
|
||
|
||
/// Report a pre-computed percentage (multi-phase extractions where byte
|
||
/// counts don't map linearly onto overall progress).
|
||
pub fn report_percentage(&self, percentage: f64) {
|
||
self.report(percentage, 0, None);
|
||
}
|
||
|
||
/// Force a final 100% event so the bar lands full before the next stage.
|
||
pub fn finish(&self) {
|
||
self.emit(100.0, 0, None);
|
||
}
|
||
|
||
fn report(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||
{
|
||
let mut last = self.last_emit.lock().unwrap();
|
||
if last.elapsed() < PROGRESS_REPORT_INTERVAL {
|
||
return;
|
||
}
|
||
*last = std::time::Instant::now();
|
||
}
|
||
self.emit(percentage, downloaded_bytes, total_bytes);
|
||
}
|
||
|
||
fn emit(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||
let progress = DownloadProgress {
|
||
browser: self.browser.clone(),
|
||
version: self.version.clone(),
|
||
downloaded_bytes,
|
||
total_bytes,
|
||
percentage: percentage.clamp(0.0, 100.0),
|
||
speed_bytes_per_sec: 0.0,
|
||
eta_seconds: None,
|
||
stage: "extracting".to_string(),
|
||
};
|
||
let _ = events::emit("download-progress", &progress);
|
||
}
|
||
}
|
||
|
||
/// A reader that passes cumulative bytes read to a callback on every read.
|
||
/// Throttling is the reporter's job, so the callback can fire freely.
|
||
struct ProgressReader<R, F: FnMut(u64)> {
|
||
inner: R,
|
||
bytes_read: u64,
|
||
on_read: F,
|
||
}
|
||
|
||
impl<R, F: FnMut(u64)> ProgressReader<R, F> {
|
||
fn new(inner: R, on_read: F) -> Self {
|
||
Self {
|
||
inner,
|
||
bytes_read: 0,
|
||
on_read,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<R: Read, F: FnMut(u64)> Read for ProgressReader<R, F> {
|
||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||
let n = self.inner.read(buf)?;
|
||
self.bytes_read += n as u64;
|
||
(self.on_read)(self.bytes_read);
|
||
Ok(n)
|
||
}
|
||
}
|
||
|
||
/// Wrap an archive file so compressed bytes consumed report linearly against
|
||
/// its on-disk size — shared by the streaming tar decoders, where stream
|
||
/// position maps monotonically onto overall extraction progress.
|
||
fn progress_file_reader(
|
||
file: File,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> io::Result<impl Read + '_> {
|
||
let compressed_total = file.metadata()?.len();
|
||
Ok(ProgressReader::new(file, move |read| {
|
||
if let Some(reporter) = progress {
|
||
reporter.report_bytes(read, compressed_total);
|
||
}
|
||
}))
|
||
}
|
||
|
||
pub struct Extractor;
|
||
|
||
impl Extractor {
|
||
fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
pub fn instance() -> &'static Extractor {
|
||
&EXTRACTOR
|
||
}
|
||
|
||
// NOTE: We intentionally do not rename or sanitize ZIP entry paths.
|
||
// We only ensure paths are enclosed within the destination using zip's enclosed_name.
|
||
|
||
/// Ensure the extracted files are in the correct directory structure expected by verification
|
||
#[cfg(target_os = "linux")]
|
||
async fn ensure_correct_directory_structure(
|
||
&self,
|
||
dest_dir: &Path,
|
||
exe_path: &Path,
|
||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||
// Determine browser type from the destination directory path
|
||
let browser_type = if dest_dir.to_string_lossy().contains("wayfern") {
|
||
"wayfern"
|
||
} else {
|
||
return Ok(());
|
||
};
|
||
|
||
// For Wayfern on Linux, we expect the executable directly under version directory
|
||
// e.g., binaries/wayfern/<version>/wayfern, without an extra subdirectory
|
||
if browser_type == "wayfern" {
|
||
return Ok(());
|
||
}
|
||
|
||
let expected_subdir = dest_dir.join(browser_type);
|
||
|
||
// If the executable is not in the expected subdirectory, create the structure
|
||
if !exe_path.starts_with(&expected_subdir) {
|
||
log::info!("Reorganizing directory structure for {}", browser_type);
|
||
|
||
// Create the expected subdirectory
|
||
std::fs::create_dir_all(&expected_subdir)?;
|
||
|
||
// Move all files from the root to the subdirectory
|
||
if let Ok(entries) = std::fs::read_dir(dest_dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let file_name = match path.file_name() {
|
||
Some(name) => name,
|
||
None => continue,
|
||
};
|
||
|
||
// Skip the subdirectory we just created
|
||
if path == expected_subdir {
|
||
continue;
|
||
}
|
||
|
||
let target_path = expected_subdir.join(file_name);
|
||
|
||
// Move the file/directory
|
||
if let Err(e) = std::fs::rename(&path, &target_path) {
|
||
log::info!(
|
||
"Warning: Failed to move {} to {}: {}",
|
||
path.display(),
|
||
target_path.display(),
|
||
e
|
||
);
|
||
} else {
|
||
log::info!("Moved {} to {}", path.display(), target_path.display());
|
||
}
|
||
}
|
||
}
|
||
|
||
log::info!("Directory structure reorganized for {}", browser_type);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
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 _ = events::emit("download-progress", &progress);
|
||
|
||
// Reports incremental extraction progress to the UI. Formats without a
|
||
// measurable byte stream (MSI, plain EXE/AppImage copies) simply never
|
||
// report, and the frontend falls back to an indeterminate bar.
|
||
let reporter = ExtractionReporter::new(browser_type.as_str().to_string(), version.to_string());
|
||
|
||
log::info!(
|
||
"Starting extraction of {} for browser {} version {}",
|
||
archive_path.display(),
|
||
browser_type.as_str(),
|
||
version
|
||
);
|
||
|
||
// Detect the actual file type by reading the file header
|
||
let actual_format = self.detect_file_format(archive_path).map_err(|e| {
|
||
format!(
|
||
"Failed to detect file format for {}: {}",
|
||
archive_path.display(),
|
||
e
|
||
)
|
||
})?;
|
||
log::info!("Detected format: {actual_format}");
|
||
|
||
let extraction_result = match actual_format.as_str() {
|
||
"dmg" => {
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
self.extract_dmg(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||
format!("DMG extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
Err(format!("DMG extraction is only supported on macOS, but {} {} requires DMG extraction", browser_type.as_str(), version).into())
|
||
}
|
||
}
|
||
"zip" => {
|
||
self.extract_zip(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||
format!("ZIP extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"tar.xz" => {
|
||
self.extract_tar_xz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||
format!("TAR.XZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"tar.bz2" => {
|
||
self.extract_tar_bz2(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||
format!("TAR.BZ2 extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"tar.gz" => {
|
||
self.extract_tar_gz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||
format!("TAR.GZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"msi" => {
|
||
self.extract_msi(archive_path, dest_dir).await.map_err(|e| {
|
||
format!("MSI extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"exe" => {
|
||
// For Windows EXE files, some may be self-extracting archives, others are installers
|
||
self
|
||
.handle_exe_file(archive_path, dest_dir, browser_type.clone())
|
||
.await
|
||
.map_err(|e| {
|
||
format!("EXE handling failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
"appimage" => {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
self.handle_appimage(archive_path, dest_dir).await.map_err(|e| {
|
||
format!("AppImage handling failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||
})
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
Err(format!("AppImage is only supported on Linux, but {} {} requires AppImage handling", browser_type.as_str(), version).into())
|
||
}
|
||
}
|
||
_ => {
|
||
Err(format!(
|
||
"Unsupported archive format for {} {}: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format. File: {}",
|
||
browser_type.as_str(),
|
||
version,
|
||
archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"),
|
||
actual_format,
|
||
archive_path.display()
|
||
).into())
|
||
}
|
||
};
|
||
|
||
match extraction_result {
|
||
Ok(path) => {
|
||
reporter.finish();
|
||
|
||
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||
// but only if there's actually something to remove. Calling the
|
||
// modify-class `removexattr` syscall on a file without quarantine still
|
||
// fires macOS Sequoia's App Management TCC notification, so we skip
|
||
// the call entirely when the attribute is absent.
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if has_quarantine_attr(dest_dir) {
|
||
let _ = tokio::process::Command::new("xattr")
|
||
.args([
|
||
"-dr",
|
||
"com.apple.quarantine",
|
||
dest_dir.to_str().unwrap_or("."),
|
||
])
|
||
.output()
|
||
.await;
|
||
}
|
||
}
|
||
|
||
log::info!(
|
||
"Successfully extracted {} {} to: {}",
|
||
browser_type.as_str(),
|
||
version,
|
||
path.display()
|
||
);
|
||
Ok(path)
|
||
}
|
||
Err(e) => {
|
||
log::error!(
|
||
"Extraction failed for {} {}: {}",
|
||
browser_type.as_str(),
|
||
version,
|
||
e
|
||
);
|
||
Err(e)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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>> {
|
||
// Check file extension first for container formats (DMG, MSI) whose internal
|
||
// compression makes magic bytes unreliable
|
||
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
|
||
match ext.to_lowercase().as_str() {
|
||
"dmg" => return Ok("dmg".to_string()),
|
||
"msi" => return Ok("msi".to_string()),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let mut file = File::open(file_path)?;
|
||
let mut buffer = [0u8; 12];
|
||
file.read_exact(&mut buffer)?;
|
||
|
||
// Check magic numbers for other 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 MSI files (Microsoft Installer)
|
||
if buffer[0..8] == [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {
|
||
return Ok("msi".to_string());
|
||
}
|
||
|
||
// 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());
|
||
}
|
||
|
||
// 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()),
|
||
"msi" => Ok("msi".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()),
|
||
"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,
|
||
dest_dir: &Path,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!(
|
||
"Extracting DMG: {} to {}",
|
||
dmg_path.display(),
|
||
dest_dir.display()
|
||
);
|
||
|
||
// Create a temporary mount point
|
||
let mount_point = std::env::temp_dir().join(format!(
|
||
"donut_mount_{}",
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_secs()
|
||
));
|
||
create_dir_all(&mount_point)?;
|
||
|
||
log::info!("Created mount point: {}", mount_point.display());
|
||
|
||
// Mount the DMG
|
||
let output = Command::new("hdiutil")
|
||
.args([
|
||
"attach",
|
||
"-nobrowse",
|
||
"-noverify",
|
||
"-noautoopen",
|
||
"-mountpoint",
|
||
mount_point.to_str().unwrap(),
|
||
dmg_path.to_str().unwrap(),
|
||
])
|
||
.stdin(std::process::Stdio::null())
|
||
.output()
|
||
.await?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
|
||
|
||
// Clean up mount point before returning error
|
||
let _ = fs::remove_dir_all(&mount_point);
|
||
|
||
return Err(format!("Failed to mount DMG: {stderr}").into());
|
||
}
|
||
|
||
log::info!("Successfully mounted DMG");
|
||
|
||
// Find the .app directory in the mount point
|
||
let app_result = self.find_app_in_directory(&mount_point).await;
|
||
|
||
let app_entry = match app_result {
|
||
Ok(app_path) => app_path,
|
||
Err(e) => {
|
||
log::error!("Failed to find .app in mount point: {e}");
|
||
|
||
// Try to unmount before returning error
|
||
let _ = Command::new("hdiutil")
|
||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||
.output()
|
||
.await;
|
||
let _ = fs::remove_dir_all(&mount_point);
|
||
|
||
return Err("No .app found after extraction".into());
|
||
}
|
||
};
|
||
|
||
log::info!("Found .app bundle: {}", app_entry.display());
|
||
|
||
// Copy the .app to the destination
|
||
let app_path = dest_dir.join(app_entry.file_name().unwrap());
|
||
|
||
log::info!("Copying .app to: {}", app_path.display());
|
||
|
||
// The copy is the long pole of DMG extraction; size up the source once so
|
||
// we can report real progress by polling the destination while cp runs.
|
||
// report_bytes no-ops on a 0 total, so the None path skips both walks.
|
||
let total_size = if progress.is_some() {
|
||
dir_size(&app_entry)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// `-X` strips extended attributes (notably com.apple.quarantine) during
|
||
// the copy itself. Without it, `cp -R` preserves quarantine from the
|
||
// mounted DMG, which then has to be removed with `xattr -dr` — and that
|
||
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
|
||
// Management TCC notification ("Donut.app was prevented from modifying
|
||
// apps on your Mac"). Stripping at copy time is silent.
|
||
let copy_src = app_entry.to_str().unwrap().to_string();
|
||
let copy_dst = app_path.to_str().unwrap().to_string();
|
||
let mut copy_task = tokio::spawn(async move {
|
||
Command::new("cp")
|
||
.args(["-RX", ©_src, ©_dst])
|
||
.output()
|
||
.await
|
||
});
|
||
|
||
let output = loop {
|
||
tokio::select! {
|
||
result = &mut copy_task => {
|
||
break result.map_err(|e| format!("Copy task failed: {e}"))??;
|
||
}
|
||
() = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
|
||
if let Some(reporter) = progress {
|
||
reporter.report_bytes(dir_size(&app_path), total_size);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
log::error!("Failed to copy app: {stderr}");
|
||
|
||
// Unmount before returning error
|
||
let _ = Command::new("hdiutil")
|
||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||
.output()
|
||
.await;
|
||
let _ = fs::remove_dir_all(&mount_point);
|
||
|
||
return Err(format!("Failed to copy app: {stderr}").into());
|
||
}
|
||
|
||
log::info!("Successfully copied .app bundle");
|
||
|
||
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
|
||
// — but only if it's actually present. A no-op `removexattr` syscall on a
|
||
// signed .app bundle still trips macOS Sequoia's App Management privacy
|
||
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
|
||
// even when no modification actually happens, so we gate the call behind
|
||
// a read-only `getxattr` check.
|
||
if has_quarantine_attr(&app_path) {
|
||
let _ = Command::new("xattr")
|
||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||
.output()
|
||
.await;
|
||
log::info!("Removed quarantine attributes");
|
||
} else {
|
||
log::info!("No quarantine attribute on .app, skipping xattr removal");
|
||
}
|
||
|
||
// Unmount the DMG
|
||
let output = Command::new("hdiutil")
|
||
.args(["detach", mount_point.to_str().unwrap()])
|
||
.output()
|
||
.await?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
log::warn!("Warning: Failed to unmount DMG: {stderr}");
|
||
// Don't fail if unmount fails - the extraction was successful
|
||
} else {
|
||
log::info!("Successfully unmounted DMG");
|
||
}
|
||
|
||
// Clean up mount point directory
|
||
let _ = fs::remove_dir_all(&mount_point);
|
||
|
||
Ok(app_path)
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
async fn find_app_in_directory(
|
||
&self,
|
||
dir: &Path,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
self.find_app_recursive(dir, 0).await
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
async fn find_app_recursive(
|
||
&self,
|
||
dir: &Path,
|
||
depth: usize,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
// Limit search depth to avoid infinite loops
|
||
if depth > 4 {
|
||
return Err("Maximum search depth reached".into());
|
||
}
|
||
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
let mut subdirs = Vec::new();
|
||
let mut hidden_subdirs = Vec::new();
|
||
|
||
// First pass: look for .app bundles directly
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
if let Some(extension) = path.extension() {
|
||
if extension == "app" {
|
||
log::info!("Found .app bundle at depth {}: {}", depth, path.display());
|
||
return Ok(path);
|
||
}
|
||
}
|
||
|
||
// Collect subdirectories for second pass
|
||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||
if filename.starts_with('.') {
|
||
// Hidden directories - search these with lower priority
|
||
hidden_subdirs.push(path);
|
||
} else {
|
||
// Regular directories - search these first
|
||
subdirs.push(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: search regular subdirectories first
|
||
for subdir in subdirs {
|
||
// Skip common directories that are unlikely to contain .app files
|
||
let dirname = subdir.file_name().unwrap_or_default().to_string_lossy();
|
||
if matches!(
|
||
dirname.as_ref(),
|
||
"Documents" | "Downloads" | "Desktop" | "Library" | "System" | "tmp" | "var"
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
if let Ok(result) = Box::pin(self.find_app_recursive(&subdir, depth + 1)).await {
|
||
return Ok(result);
|
||
}
|
||
}
|
||
|
||
// Third pass: search hidden directories if nothing found in regular ones
|
||
for hidden_dir in hidden_subdirs {
|
||
if let Ok(result) = Box::pin(self.find_app_recursive(&hidden_dir, depth + 1)).await {
|
||
return Ok(result);
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(format!("No .app found in directory: {}", dir.display()).into())
|
||
}
|
||
|
||
pub async fn extract_zip(
|
||
&self,
|
||
zip_path: &Path,
|
||
dest_dir: &Path,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Extracting ZIP archive: {}", zip_path.display());
|
||
std::fs::create_dir_all(dest_dir)?;
|
||
|
||
let file = File::open(zip_path)
|
||
.map_err(|e| format!("Failed to open ZIP file {}: {}", zip_path.display(), e))?;
|
||
|
||
let mut archive = zip::ZipArchive::new(BufReader::new(file))
|
||
.map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?;
|
||
|
||
log::info!("ZIP archive contains {} files", archive.len());
|
||
|
||
// Total uncompressed size, known from the central directory without any
|
||
// decompression. None for archives using data descriptors — those get no
|
||
// byte progress (the UI falls back to an indeterminate bar).
|
||
let total_uncompressed: Option<u64> = archive
|
||
.decompressed_size()
|
||
.and_then(|total| u64::try_from(total).ok())
|
||
.filter(|total| *total > 0);
|
||
let mut extracted_bytes: u64 = 0;
|
||
|
||
for i in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(i)
|
||
.map_err(|e| format!("Failed to read ZIP entry at index {i}: {e}"))?;
|
||
|
||
// Use enclosed_name to prevent path traversal; do not modify names otherwise
|
||
let enclosed = entry
|
||
.enclosed_name()
|
||
.ok_or_else(|| format!("ZIP contains an invalid entry path: {}", entry.name()))?;
|
||
|
||
let outpath = dest_dir.join(enclosed);
|
||
|
||
// Handle directories and files
|
||
if entry.is_dir() {
|
||
std::fs::create_dir_all(&outpath)
|
||
.map_err(|e| format!("Failed to create directory {}: {}", outpath.display(), e))?;
|
||
} else {
|
||
if let Some(parent) = outpath.parent() {
|
||
std::fs::create_dir_all(parent).map_err(|e| {
|
||
format!(
|
||
"Failed to create parent directory {}: {}",
|
||
parent.display(),
|
||
e
|
||
)
|
||
})?;
|
||
}
|
||
|
||
let mut outfile = File::create(&outpath)
|
||
.map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?;
|
||
let entry_size = entry.size();
|
||
let already_extracted = extracted_bytes;
|
||
let mut reader = ProgressReader::new(&mut entry, |read| {
|
||
if let (Some(reporter), Some(total)) = (progress, total_uncompressed) {
|
||
reporter.report_bytes(already_extracted + read, total);
|
||
}
|
||
});
|
||
io::copy(&mut reader, &mut outfile)
|
||
.map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?;
|
||
extracted_bytes = extracted_bytes.saturating_add(entry_size);
|
||
|
||
// Set executable permissions on Unix-like systems based on stored mode
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
if let Some(mode) = entry.unix_mode() {
|
||
let permissions = std::fs::Permissions::from_mode(mode);
|
||
std::fs::set_permissions(&outpath, permissions)
|
||
.map_err(|e| format!("Failed to set permissions for {}: {}", outpath.display(), e))?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
log::info!("ZIP extraction completed.");
|
||
|
||
self.flatten_single_directory_archive(dest_dir)?;
|
||
|
||
log::info!("Searching for executable...");
|
||
self
|
||
.find_extracted_executable(dest_dir)
|
||
.await
|
||
.map_err(|e| format!("Failed to find executable after ZIP extraction: {e}").into())
|
||
}
|
||
|
||
pub async fn extract_tar_gz(
|
||
&self,
|
||
tar_path: &Path,
|
||
dest_dir: &Path,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Extracting tar.gz archive: {}", tar_path.display());
|
||
std::fs::create_dir_all(dest_dir)?;
|
||
|
||
let file = File::open(tar_path)?;
|
||
let counted = progress_file_reader(file, progress)?;
|
||
let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(counted));
|
||
let mut archive = tar::Archive::new(gz_decoder);
|
||
|
||
archive.unpack(dest_dir)?;
|
||
|
||
// Set executable permissions for extracted files
|
||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||
|
||
log::info!("tar.gz extraction completed.");
|
||
self.flatten_single_directory_archive(dest_dir)?;
|
||
log::info!("Searching for executable...");
|
||
self.find_extracted_executable(dest_dir).await
|
||
}
|
||
|
||
pub async fn extract_tar_bz2(
|
||
&self,
|
||
tar_path: &Path,
|
||
dest_dir: &Path,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Extracting tar.bz2 archive: {}", tar_path.display());
|
||
std::fs::create_dir_all(dest_dir)?;
|
||
|
||
let file = File::open(tar_path)?;
|
||
let counted = progress_file_reader(file, progress)?;
|
||
let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(counted));
|
||
let mut archive = tar::Archive::new(bz2_decoder);
|
||
|
||
archive.unpack(dest_dir)?;
|
||
|
||
// Set executable permissions for extracted files
|
||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||
|
||
log::info!("tar.bz2 extraction completed.");
|
||
self.flatten_single_directory_archive(dest_dir)?;
|
||
log::info!("Searching for executable...");
|
||
self.find_extracted_executable(dest_dir).await
|
||
}
|
||
|
||
pub async fn extract_tar_xz(
|
||
&self,
|
||
tar_path: &Path,
|
||
dest_dir: &Path,
|
||
progress: Option<&ExtractionReporter>,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Extracting tar.xz archive: {}", tar_path.display());
|
||
std::fs::create_dir_all(dest_dir)?;
|
||
|
||
let file = File::open(tar_path)?;
|
||
let mut buf_reader = BufReader::new(file);
|
||
|
||
// Read the entire file into memory for lzma-rs
|
||
let mut compressed_data = Vec::new();
|
||
buf_reader.read_to_end(&mut compressed_data)?;
|
||
let compressed_total = compressed_data.len() as u64;
|
||
|
||
// Two phases with no shared byte scale: CPU-bound LZMA decompression
|
||
// dominates, so compressed bytes consumed map to 0–80%, and the tar
|
||
// unpack of the decompressed data to 80–100%.
|
||
let mut decompressed_data = Vec::new();
|
||
let counted_input = ProgressReader::new(std::io::Cursor::new(compressed_data), |read| {
|
||
if let Some(reporter) = progress {
|
||
if compressed_total > 0 {
|
||
reporter
|
||
.report_percentage(80.0 * read.min(compressed_total) as f64 / compressed_total as f64);
|
||
}
|
||
}
|
||
});
|
||
lzma_rs::xz_decompress(&mut BufReader::new(counted_input), &mut decompressed_data)?;
|
||
|
||
// Create tar archive from decompressed data
|
||
let decompressed_total = decompressed_data.len() as u64;
|
||
let counted_tar = ProgressReader::new(std::io::Cursor::new(decompressed_data), |read| {
|
||
if let Some(reporter) = progress {
|
||
if decompressed_total > 0 {
|
||
reporter.report_percentage(
|
||
80.0 + 20.0 * read.min(decompressed_total) as f64 / decompressed_total as f64,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
let mut archive = tar::Archive::new(counted_tar);
|
||
|
||
archive.unpack(dest_dir)?;
|
||
|
||
// Set executable permissions for extracted files
|
||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||
|
||
log::info!("tar.xz extraction completed.");
|
||
self.flatten_single_directory_archive(dest_dir)?;
|
||
log::info!("Searching for executable...");
|
||
self.find_extracted_executable(dest_dir).await
|
||
}
|
||
|
||
pub async fn extract_msi(
|
||
&self,
|
||
msi_path: &Path,
|
||
dest_dir: &Path,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Extracting MSI archive: {}", msi_path.display());
|
||
std::fs::create_dir_all(dest_dir)?;
|
||
|
||
// Extract MSI in a separate scope to avoid Send issues
|
||
{
|
||
let mut extractor = msi_extract::MsiExtractor::from_path(msi_path)?;
|
||
extractor.to(dest_dir);
|
||
}
|
||
|
||
log::info!("MSI extraction completed.");
|
||
self.flatten_single_directory_archive(dest_dir)?;
|
||
log::info!("Searching for executable...");
|
||
self.find_extracted_executable(dest_dir).await
|
||
}
|
||
|
||
#[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>> {
|
||
std::fs::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_recursive(&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>> {
|
||
{
|
||
let _ = browser_type;
|
||
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)
|
||
}
|
||
}
|
||
|
||
fn flatten_single_directory_archive(
|
||
&self,
|
||
dest_dir: &Path,
|
||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||
let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect();
|
||
|
||
let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"];
|
||
|
||
let mut dirs = Vec::new();
|
||
let mut has_non_archive_files = false;
|
||
|
||
for entry in &entries {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
dirs.push(path);
|
||
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||
if !archive_extensions.contains(&ext.to_lowercase().as_str()) {
|
||
has_non_archive_files = true;
|
||
}
|
||
} else {
|
||
has_non_archive_files = true;
|
||
}
|
||
}
|
||
|
||
if dirs.len() == 1 && !has_non_archive_files {
|
||
let single_dir = &dirs[0];
|
||
|
||
if single_dir.extension().is_some_and(|ext| ext == "app") {
|
||
log::info!(
|
||
"Skipping flatten: {} is a macOS app bundle",
|
||
single_dir.display()
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
log::info!(
|
||
"Flattening single-directory archive: moving contents of {} to {}",
|
||
single_dir.display(),
|
||
dest_dir.display()
|
||
);
|
||
|
||
let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect();
|
||
|
||
for entry in inner_entries {
|
||
let source = entry.path();
|
||
let file_name = match source.file_name() {
|
||
Some(name) => name.to_owned(),
|
||
None => continue,
|
||
};
|
||
let target = dest_dir.join(&file_name);
|
||
fs::rename(&source, &target).map_err(|e| {
|
||
format!(
|
||
"Failed to move {} to {}: {}",
|
||
source.display(),
|
||
target.display(),
|
||
e
|
||
)
|
||
})?;
|
||
}
|
||
|
||
fs::remove_dir(single_dir).map_err(|e| {
|
||
format!(
|
||
"Failed to remove empty directory {}: {}",
|
||
single_dir.display(),
|
||
e
|
||
)
|
||
})?;
|
||
|
||
log::info!("Successfully flattened archive directory structure");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
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")]
|
||
{
|
||
let result = self.find_linux_executable(dest_dir).await;
|
||
|
||
// If we found an executable, ensure it's in the correct directory structure
|
||
// that the verification expects
|
||
if let Ok(exe_path) = &result {
|
||
self
|
||
.ensure_correct_directory_structure(dest_dir, exe_path)
|
||
.await?;
|
||
}
|
||
|
||
result
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
async fn find_macos_app(
|
||
&self,
|
||
dest_dir: &Path,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Searching for .app bundle in: {}", dest_dir.display());
|
||
|
||
// Use the enhanced recursive search
|
||
match self.find_app_in_directory(dest_dir).await {
|
||
Ok(app_path) => {
|
||
// Check if the app is in a subdirectory and move it to the root if needed
|
||
let app_parent = app_path.parent().unwrap();
|
||
if app_parent != dest_dir {
|
||
log::info!(
|
||
"Found .app in subdirectory, moving to root: {} -> {}",
|
||
app_path.display(),
|
||
dest_dir.display()
|
||
);
|
||
let target_path = dest_dir.join(app_path.file_name().unwrap());
|
||
|
||
// Move the app to the root destination directory
|
||
fs::rename(&app_path, &target_path)?;
|
||
|
||
// Try to clean up the now-empty subdirectory (ignore errors)
|
||
if let Some(parent_dir) = app_path.parent() {
|
||
if parent_dir != dest_dir {
|
||
let _ = fs::remove_dir_all(parent_dir);
|
||
}
|
||
}
|
||
|
||
log::info!("Successfully moved .app to: {}", target_path.display());
|
||
Ok(target_path)
|
||
} else {
|
||
log::info!("Found .app at root level: {}", app_path.display());
|
||
Ok(app_path)
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::info!("Failed to find .app bundle: {e}");
|
||
Err("No .app found after extraction".into())
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
async fn find_windows_executable(
|
||
&self,
|
||
dest_dir: &Path,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!(
|
||
"Searching for Windows executable in: {}",
|
||
dest_dir.display()
|
||
);
|
||
|
||
// Look for .exe files, preferring main browser executables. Wayfern is the
|
||
// current name; chromium/chrome cover builds extracted before the rename.
|
||
let priority_exe_names = ["wayfern.exe", "chromium.exe", "chrome.exe"];
|
||
|
||
// First try priority executable names
|
||
for exe_name in &priority_exe_names {
|
||
let exe_path = dest_dir.join(exe_name);
|
||
if exe_path.exists() {
|
||
log::info!("Found priority executable: {}", exe_path.display());
|
||
return Ok(exe_path);
|
||
}
|
||
}
|
||
|
||
// Recursively search for executables with depth limit
|
||
match self.find_windows_executable_recursive(dest_dir, 0, 3).await {
|
||
Ok(exe_path) => {
|
||
log::info!(
|
||
"Found executable via recursive search: {}",
|
||
exe_path.display()
|
||
);
|
||
Ok(exe_path)
|
||
}
|
||
Err(_) => Err("No executable found after extraction".into()),
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
#[allow(clippy::type_complexity)]
|
||
fn find_windows_executable_recursive<'a>(
|
||
&'a self,
|
||
dir: &'a Path,
|
||
depth: usize,
|
||
max_depth: usize,
|
||
) -> std::pin::Pin<
|
||
Box<
|
||
dyn std::future::Future<Output = Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>>
|
||
+ Send
|
||
+ 'a,
|
||
>,
|
||
> {
|
||
Box::pin(async move {
|
||
if depth > max_depth {
|
||
return Err("Maximum search depth reached".into());
|
||
}
|
||
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
let mut dirs_to_search = Vec::new();
|
||
|
||
// First pass: look for .exe files in current directory
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
|
||
if path.is_file()
|
||
&& path
|
||
.extension()
|
||
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
|
||
{
|
||
let file_name = path
|
||
.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
|
||
// Check if it's a browser executable
|
||
if file_name.contains("chrome")
|
||
|| file_name.contains("chromium")
|
||
|| file_name.contains("browser")
|
||
|| file_name.contains("wayfern")
|
||
{
|
||
return Ok(path);
|
||
}
|
||
} else if path.is_dir() {
|
||
// Collect directories for later search
|
||
dirs_to_search.push(path);
|
||
}
|
||
}
|
||
|
||
// Second pass: search subdirectories
|
||
for subdir in dirs_to_search {
|
||
if let Ok(result) = self
|
||
.find_windows_executable_recursive(&subdir, depth + 1, max_depth)
|
||
.await
|
||
{
|
||
return Ok(result);
|
||
}
|
||
}
|
||
|
||
// Third pass: if no browser-specific executable found, return any .exe
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_file()
|
||
&& path
|
||
.extension()
|
||
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
|
||
{
|
||
return Ok(path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Err("No executable found".into())
|
||
})
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
async fn find_linux_executable(
|
||
&self,
|
||
dest_dir: &Path,
|
||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||
log::info!("Searching for Linux executable in: {}", dest_dir.display());
|
||
|
||
// Enhanced list of common browser executable names, Wayfern first since it
|
||
// is the current name. Chrome/Chromium cover builds extracted before the
|
||
// rename.
|
||
let exe_names = [
|
||
// Wayfern variants (current naming)
|
||
"wayfern",
|
||
"wayfern-bin",
|
||
"wayfern-browser",
|
||
// Chrome/Chromium variants (builds extracted before the rename)
|
||
"chrome",
|
||
"chromium",
|
||
"chromium-browser",
|
||
"chromium-bin",
|
||
];
|
||
|
||
// First, try direct lookup in the main directory
|
||
for exe_name in &exe_names {
|
||
let exe_path = dest_dir.join(exe_name);
|
||
if exe_path.exists() && self.is_executable(&exe_path) {
|
||
log::info!("Found executable at root level: {}", exe_path.display());
|
||
return Ok(exe_path);
|
||
}
|
||
}
|
||
|
||
// Enhanced list of common Linux subdirectories to search
|
||
let subdirs = [
|
||
"bin",
|
||
"usr/bin",
|
||
"usr/local/bin",
|
||
"opt",
|
||
"sbin",
|
||
"usr/sbin",
|
||
"wayfern",
|
||
"wayfern-linux",
|
||
"chrome",
|
||
"chromium",
|
||
"chrome-linux",
|
||
".",
|
||
"./",
|
||
"Browser",
|
||
"browser",
|
||
"usr/lib/chromium",
|
||
"usr/share/applications",
|
||
"usr/bin",
|
||
"AppRun",
|
||
];
|
||
|
||
// Search in subdirectories
|
||
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 {
|
||
let exe_path = subdir_path.join(exe_name);
|
||
if exe_path.exists() && self.is_executable(&exe_path) {
|
||
log::info!("Found executable in subdirectory: {}", exe_path.display());
|
||
return Ok(exe_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for AppImage files
|
||
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) {
|
||
log::info!("Found AppImage: {}", path.display());
|
||
return Ok(path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Last resort: recursive search for any executable file
|
||
log::info!("Performing recursive search for executables...");
|
||
match self.find_any_executable_recursive(dest_dir, 0).await {
|
||
Ok(path) => {
|
||
log::info!("Found executable via recursive search: {}", path.display());
|
||
Ok(path)
|
||
}
|
||
Err(e) => {
|
||
// List all files in the directory for debugging
|
||
log::info!("Failed to find executable. Directory contents:");
|
||
if let Ok(entries) = fs::read_dir(dest_dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let is_exec = if path.is_file() {
|
||
self.is_executable(&path)
|
||
} else {
|
||
false
|
||
};
|
||
log::info!(" {} (executable: {})", path.display(), is_exec);
|
||
}
|
||
}
|
||
Err(
|
||
format!(
|
||
"No executable found in {} after extraction. Original error: {}",
|
||
dest_dir.display(),
|
||
e
|
||
)
|
||
.into(),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 recursively for all files in a directory
|
||
#[cfg(unix)]
|
||
async fn set_executable_permissions_recursive(
|
||
&self,
|
||
dir: &Path,
|
||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_file() {
|
||
// Check if file looks like it should be executable
|
||
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("chrome")
|
||
|| name_lower.contains("brave")
|
||
|| name_lower.contains("zen")
|
||
|| name_lower.contains("wayfern")
|
||
|| name_lower.ends_with(".appimage")
|
||
|| !name_lower.contains('.')
|
||
{
|
||
// Likely an executable, set permissions
|
||
let mut permissions = path.metadata()?.permissions();
|
||
let current_mode = permissions.mode();
|
||
let new_mode = current_mode | 0o755; // rwxr-xr-x
|
||
permissions.set_mode(new_mode);
|
||
std::fs::set_permissions(&path, permissions)?;
|
||
}
|
||
}
|
||
} else if path.is_dir() {
|
||
// Recursively process subdirectories
|
||
Box::pin(self.set_executable_permissions_recursive(&path)).await?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
async fn set_executable_permissions_recursive(
|
||
&self,
|
||
_dir: &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();
|
||
let mut potential_executables = 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("chrome")
|
||
|| name_lower.contains("brave")
|
||
|| name_lower.contains("zen")
|
||
|| name_lower.contains("wayfern")
|
||
|| file_name.ends_with(".AppImage")
|
||
{
|
||
log::info!(
|
||
"Found priority executable at depth {}: {}",
|
||
depth,
|
||
path.display()
|
||
);
|
||
return Ok(path);
|
||
}
|
||
// Collect other executables as potential candidates
|
||
potential_executables.push(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);
|
||
}
|
||
}
|
||
|
||
// Third pass: if no browser-specific executable found, try any executable
|
||
if !potential_executables.is_empty() {
|
||
// Sort by filename to prefer more likely candidates
|
||
potential_executables.sort_by(|a, b| {
|
||
let a_name = a
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_lowercase();
|
||
let b_name = b
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_lowercase();
|
||
|
||
// Prefer shorter names (likely main executables)
|
||
a_name.len().cmp(&b_name.len())
|
||
});
|
||
|
||
log::info!(
|
||
"Found potential executable at depth {}: {}",
|
||
depth,
|
||
potential_executables[0].display()
|
||
);
|
||
return Ok(potential_executables[0].clone());
|
||
}
|
||
}
|
||
|
||
Err(format!("No executable found in directory: {}", dir.display()).into())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::fs::File;
|
||
use std::io::Write;
|
||
use tempfile::TempDir;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
use std::fs::create_dir_all;
|
||
|
||
#[test]
|
||
fn test_format_detection_zip() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let zip_path = temp_dir.path().join("test.zip");
|
||
|
||
// Create a file with ZIP magic number
|
||
let mut file = File::create(&zip_path).unwrap();
|
||
file.write_all(&[0x50, 0x4B, 0x03, 0x04]).unwrap(); // ZIP magic
|
||
file.write_all(&[0; 8]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&zip_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "zip");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_dmg_by_extension() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let dmg_path = temp_dir.path().join("test.dmg");
|
||
|
||
// Create a file (magic number won't match, but extension will)
|
||
let mut file = File::create(&dmg_path).unwrap();
|
||
file.write_all(b"fake dmg content").unwrap();
|
||
|
||
let result = extractor.detect_file_format(&dmg_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "dmg");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_exe() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let exe_path = temp_dir.path().join("test.exe");
|
||
|
||
// Create a file with PE header
|
||
let mut file = File::create(&exe_path).unwrap();
|
||
file.write_all(&[0x4D, 0x5A]).unwrap(); // PE magic
|
||
file.write_all(&[0; 10]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&exe_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "exe");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_tar_gz() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let tar_gz_path = temp_dir.path().join("test.tar.gz");
|
||
|
||
// Create a file with gzip magic
|
||
let mut file = File::create(&tar_gz_path).unwrap();
|
||
file.write_all(&[0x1F, 0x8B, 0x08]).unwrap(); // gzip magic
|
||
file.write_all(&[0; 9]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&tar_gz_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "tar.gz");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_tar_bz2() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let tar_bz2_path = temp_dir.path().join("test.tar.bz2");
|
||
|
||
// Create a file with bzip2 magic
|
||
let mut file = File::create(&tar_bz2_path).unwrap();
|
||
file.write_all(&[0x42, 0x5A, 0x68]).unwrap(); // bzip2 magic
|
||
file.write_all(&[0; 9]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&tar_bz2_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "tar.bz2");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_tar_xz() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let tar_xz_path = temp_dir.path().join("test.tar.xz");
|
||
|
||
// Create a file with xz magic
|
||
let mut file = File::create(&tar_xz_path).unwrap();
|
||
file
|
||
.write_all(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])
|
||
.unwrap(); // xz magic
|
||
file.write_all(&[0; 6]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&tar_xz_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "tar.xz");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_msi() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let msi_path = temp_dir.path().join("test.msi");
|
||
|
||
// Create a file with MSI magic
|
||
let mut file = File::create(&msi_path).unwrap();
|
||
file
|
||
.write_all(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])
|
||
.unwrap(); // MSI magic
|
||
file.write_all(&[0; 4]).unwrap(); // padding
|
||
|
||
let result = extractor.detect_file_format(&msi_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "msi");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_detection_msi_by_extension() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let msi_path = temp_dir.path().join("test.msi");
|
||
|
||
// Create a file (magic number won't match, but extension will)
|
||
let mut file = File::create(&msi_path).unwrap();
|
||
file.write_all(b"fake msi content").unwrap();
|
||
|
||
let result = extractor.detect_file_format(&msi_path);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "msi");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_zip_reports_progress() {
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
#[derive(Default)]
|
||
struct CapturingEmitter(Mutex<Vec<serde_json::Value>>);
|
||
|
||
impl crate::events::EventEmitter for CapturingEmitter {
|
||
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
|
||
if event == "download-progress" {
|
||
self.0.lock().unwrap().push(payload);
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
let captured = Arc::new(CapturingEmitter::default());
|
||
// The global emitter can only be set once per process; if another test ever
|
||
// claims it first we lose observability, so only assert on captured events
|
||
// when this test's emitter actually won.
|
||
let emitter_installed = crate::events::set_global_emitter(captured.clone()).is_ok();
|
||
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
let dest_dir = temp_dir.path().join("extracted");
|
||
|
||
// A payload comfortably larger than io::copy's 8KB chunks so the counting
|
||
// reader fires multiple times.
|
||
let payload = vec![0x42u8; 256 * 1024];
|
||
let zip_path = temp_dir.path().join("test.zip");
|
||
{
|
||
let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file");
|
||
let mut zip = zip::ZipWriter::new(file);
|
||
let options =
|
||
zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
|
||
zip
|
||
.start_file("data.bin", options)
|
||
.expect("Failed to start zip file");
|
||
zip.write_all(&payload).expect("Failed to write to zip");
|
||
zip.finish().expect("Failed to finish zip");
|
||
}
|
||
|
||
let reporter =
|
||
ExtractionReporter::new("test-browser-zip-progress".to_string(), "1.0.0".to_string());
|
||
let result = extractor
|
||
.extract_zip(&zip_path, &dest_dir, Some(&reporter))
|
||
.await;
|
||
|
||
// Extraction itself must have worked even if no executable is found.
|
||
assert!(dest_dir.join("data.bin").exists(), "payload not extracted");
|
||
if let Err(e) = result {
|
||
assert!(
|
||
e.to_string().contains("executable"),
|
||
"unexpected extraction error: {e}"
|
||
);
|
||
}
|
||
|
||
if emitter_installed {
|
||
let events = captured.0.lock().unwrap();
|
||
let ours: Vec<_> = events
|
||
.iter()
|
||
.filter(|p| p["browser"] == "test-browser-zip-progress")
|
||
.collect();
|
||
assert!(
|
||
!ours.is_empty(),
|
||
"expected at least one extracting progress event"
|
||
);
|
||
for p in &ours {
|
||
assert_eq!(p["stage"], "extracting");
|
||
assert_eq!(p["version"], "1.0.0");
|
||
let pct = p["percentage"].as_f64().unwrap();
|
||
assert!(
|
||
(0.0..=100.0).contains(&pct),
|
||
"percentage out of range: {pct}"
|
||
);
|
||
assert_eq!(p["total_bytes"].as_u64(), Some(payload.len() as u64));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_zip_with_test_archive() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
let dest_dir = temp_dir.path().join("extracted");
|
||
|
||
// Create a test ZIP archive in memory
|
||
let zip_path = temp_dir.path().join("test.zip");
|
||
{
|
||
let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file");
|
||
let mut zip = zip::ZipWriter::new(file);
|
||
|
||
let options =
|
||
zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
|
||
|
||
zip
|
||
.start_file("test.txt", options)
|
||
.expect("Failed to start zip file");
|
||
zip
|
||
.write_all(b"Hello, World!")
|
||
.expect("Failed to write to zip");
|
||
zip.finish().expect("Failed to finish zip");
|
||
}
|
||
|
||
let result = extractor.extract_zip(&zip_path, &dest_dir, None).await;
|
||
|
||
// The result might fail because we're looking for executables, but the extraction should work
|
||
// Let's check if the file was extracted regardless of the result
|
||
let extracted_file = dest_dir.join("test.txt");
|
||
assert!(extracted_file.exists(), "Extracted file should exist");
|
||
|
||
let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file");
|
||
assert_eq!(
|
||
content.trim(),
|
||
"Hello, World!",
|
||
"Extracted content should match"
|
||
);
|
||
|
||
// If the result is an error, it should be because no executable was found, not extraction failure
|
||
if let Err(e) = result {
|
||
let error_msg = e.to_string();
|
||
assert!(
|
||
error_msg.contains("No executable found") || error_msg.contains("executable"),
|
||
"Error should be about missing executable, not extraction failure: {error_msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_tar_gz_with_test_archive() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
let dest_dir = temp_dir.path().join("extracted");
|
||
|
||
// Create a test tar.gz archive in memory
|
||
let tar_gz_path = temp_dir.path().join("test.tar.gz");
|
||
{
|
||
let tar_gz_file =
|
||
std::fs::File::create(&tar_gz_path).expect("Failed to create test tar.gz file");
|
||
let enc = flate2::write::GzEncoder::new(tar_gz_file, flate2::Compression::default());
|
||
let mut tar = tar::Builder::new(enc);
|
||
|
||
let mut header = tar::Header::new_gnu();
|
||
header.set_path("test.txt").expect("Failed to set tar path");
|
||
header.set_size(13); // "Hello, World!" length
|
||
header.set_cksum();
|
||
|
||
tar
|
||
.append(&header, "Hello, World!".as_bytes())
|
||
.expect("Failed to append to tar");
|
||
tar.finish().expect("Failed to finish tar");
|
||
}
|
||
|
||
let result = extractor
|
||
.extract_tar_gz(&tar_gz_path, &dest_dir, None)
|
||
.await;
|
||
|
||
// Check if the file was extracted
|
||
let extracted_file = dest_dir.join("test.txt");
|
||
assert!(extracted_file.exists(), "Extracted file should exist");
|
||
|
||
let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file");
|
||
assert_eq!(
|
||
content.trim(),
|
||
"Hello, World!",
|
||
"Extracted content should match"
|
||
);
|
||
|
||
// If the result is an error, it should be because no executable was found, not extraction failure
|
||
if let Err(e) = result {
|
||
let error_msg = e.to_string();
|
||
assert!(
|
||
error_msg.contains("No executable found")
|
||
|| error_msg.contains("executable")
|
||
|| error_msg.contains("No .app found")
|
||
|| error_msg.contains("app not found"),
|
||
"Error should be about missing executable/app, not extraction failure: {error_msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_tar_bz2_with_test_archive() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
let dest_dir = temp_dir.path().join("extracted");
|
||
|
||
// Create a test tar.bz2 archive in memory
|
||
let tar_bz2_path = temp_dir.path().join("test.tar.bz2");
|
||
{
|
||
let tar_bz2_file =
|
||
std::fs::File::create(&tar_bz2_path).expect("Failed to create test tar.bz2 file");
|
||
let enc = bzip2::write::BzEncoder::new(tar_bz2_file, bzip2::Compression::default());
|
||
let mut tar = tar::Builder::new(enc);
|
||
|
||
let mut header = tar::Header::new_gnu();
|
||
header.set_path("test.txt").expect("Failed to set tar path");
|
||
header.set_size(13); // "Hello, World!" length
|
||
header.set_cksum();
|
||
|
||
tar
|
||
.append(&header, "Hello, World!".as_bytes())
|
||
.expect("Failed to append to tar");
|
||
tar.finish().expect("Failed to finish tar");
|
||
}
|
||
|
||
let result = extractor
|
||
.extract_tar_bz2(&tar_bz2_path, &dest_dir, None)
|
||
.await;
|
||
|
||
// Check if the file was extracted
|
||
let extracted_file = dest_dir.join("test.txt");
|
||
assert!(extracted_file.exists(), "Extracted file should exist");
|
||
|
||
let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file");
|
||
assert_eq!(
|
||
content.trim(),
|
||
"Hello, World!",
|
||
"Extracted content should match"
|
||
);
|
||
|
||
// If the result is an error, it should be because no executable was found, not extraction failure
|
||
if let Err(e) = result {
|
||
let error_msg = e.to_string();
|
||
assert!(
|
||
error_msg.contains("No executable found")
|
||
|| error_msg.contains("executable")
|
||
|| error_msg.contains("No .app found")
|
||
|| error_msg.contains("app not found"),
|
||
"Error should be about missing executable/app, not extraction failure: {error_msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_tar_xz_with_test_archive() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
let dest_dir = temp_dir.path().join("extracted");
|
||
|
||
// Create a test tar.xz archive in memory
|
||
let tar_xz_path = temp_dir.path().join("test.tar.xz");
|
||
{
|
||
// First create a tar archive in memory
|
||
let mut tar_data = Vec::new();
|
||
{
|
||
let mut tar = tar::Builder::new(&mut tar_data);
|
||
|
||
let mut header = tar::Header::new_gnu();
|
||
header.set_path("test.txt").expect("Failed to set tar path");
|
||
header.set_size(13); // "Hello, World!" length
|
||
header.set_cksum();
|
||
|
||
tar
|
||
.append(&header, "Hello, World!".as_bytes())
|
||
.expect("Failed to append to tar");
|
||
tar.finish().expect("Failed to finish tar");
|
||
}
|
||
|
||
// Then compress with xz
|
||
let tar_xz_file =
|
||
std::fs::File::create(&tar_xz_path).expect("Failed to create test tar.xz file");
|
||
let mut compressed_data = Vec::new();
|
||
lzma_rs::xz_compress(&mut std::io::Cursor::new(tar_data), &mut compressed_data)
|
||
.expect("Failed to compress with xz");
|
||
std::io::Write::write_all(&mut std::io::BufWriter::new(tar_xz_file), &compressed_data)
|
||
.expect("Failed to write compressed data");
|
||
}
|
||
|
||
let result = extractor
|
||
.extract_tar_xz(&tar_xz_path, &dest_dir, None)
|
||
.await;
|
||
|
||
// Check if the file was extracted
|
||
let extracted_file = dest_dir.join("test.txt");
|
||
assert!(extracted_file.exists(), "Extracted file should exist");
|
||
|
||
let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file");
|
||
assert_eq!(
|
||
content.trim(),
|
||
"Hello, World!",
|
||
"Extracted content should match"
|
||
);
|
||
|
||
// If the result is an error, it should be because no executable was found, not extraction failure
|
||
if let Err(e) = result {
|
||
let error_msg = e.to_string();
|
||
assert!(
|
||
error_msg.contains("No executable found")
|
||
|| error_msg.contains("executable")
|
||
|| error_msg.contains("No .app found")
|
||
|| error_msg.contains("app not found"),
|
||
"Error should be about missing executable/app, not extraction failure: {error_msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_unsupported_archive_format() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
let fake_archive = temp_dir.path().join("test.rar");
|
||
|
||
// Create a file with invalid header
|
||
let mut file = File::create(&fake_archive).unwrap();
|
||
file.write_all(b"invalid content").unwrap();
|
||
|
||
// Test format detection
|
||
let result = extractor.detect_file_format(&fake_archive);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), "unknown");
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
#[tokio::test]
|
||
async fn test_find_app_at_root_level() {
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().unwrap();
|
||
|
||
// Create a Wayfern.app directory
|
||
let wayfern_app = temp_dir.path().join("Wayfern.app");
|
||
create_dir_all(&wayfern_app).unwrap();
|
||
|
||
// Create the standard macOS app structure
|
||
let contents_dir = wayfern_app.join("Contents");
|
||
let macos_dir = contents_dir.join("MacOS");
|
||
create_dir_all(&macos_dir).unwrap();
|
||
|
||
// Create the executable
|
||
let executable = macos_dir.join("Wayfern");
|
||
File::create(&executable).unwrap();
|
||
|
||
// Test finding the app
|
||
let result = extractor.find_app_in_directory(temp_dir.path()).await;
|
||
assert!(result.is_ok());
|
||
|
||
let found_app = result.unwrap();
|
||
assert_eq!(found_app.file_name().unwrap(), "Wayfern.app");
|
||
assert!(found_app.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_executable() {
|
||
#[allow(unused_variables)]
|
||
let extractor = Extractor::instance();
|
||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||
|
||
// Create a regular file
|
||
let regular_file = temp_dir.path().join("regular.txt");
|
||
File::create(®ular_file).expect("Failed to create test file");
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// Should not be executable initially
|
||
assert!(
|
||
!extractor.is_executable(®ular_file),
|
||
"File should not be executable initially"
|
||
);
|
||
|
||
// Make it executable
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut permissions = regular_file
|
||
.metadata()
|
||
.expect("Failed to get file metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o755);
|
||
std::fs::set_permissions(®ular_file, permissions).expect("Failed to set permissions");
|
||
|
||
// Should now be executable
|
||
assert!(
|
||
extractor.is_executable(®ular_file),
|
||
"File should be executable after setting permissions"
|
||
);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
// On non-Linux systems, the is_executable method is not available
|
||
// We'll just verify the file exists since executable permissions work differently on Windows/macOS
|
||
assert!(regular_file.exists(), "Test file should exist");
|
||
|
||
// On Unix systems (but not Linux), we can still test basic permission setting
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut permissions = regular_file
|
||
.metadata()
|
||
.expect("Failed to get file metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o755);
|
||
std::fs::set_permissions(®ular_file, permissions).expect("Failed to set permissions");
|
||
|
||
// Verify the permissions were set
|
||
let new_permissions = regular_file
|
||
.metadata()
|
||
.expect("Failed to get updated metadata")
|
||
.permissions();
|
||
assert_eq!(
|
||
new_permissions.mode() & 0o777,
|
||
0o755,
|
||
"Permissions should be set to 755"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Global singleton instance
|
||
lazy_static::lazy_static! {
|
||
static ref EXTRACTOR: Extractor = Extractor::new();
|
||
}
|