diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ef9a28..38c589f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "adwaita", "ahooks", "akhilmhdh", + "anomalyco", "appimage", "appindicator", "applescript", @@ -111,6 +112,7 @@ "mstone", "msvc", "msys", + "muda", "mypy", "noarchive", "nobrowse", @@ -123,6 +125,7 @@ "ntlm", "numpy", "objc", + "opencode", "orhun", "orjson", "osascript", @@ -149,6 +152,7 @@ "pyoxidizer", "pytest", "pyyaml", + "quic", "reportingpolicy", "reqwest", "ridedott", @@ -216,6 +220,7 @@ "xattr", "xfconf", "xsettings", + "ZHIPU", "zhom", "zipball", "zoneinfo" diff --git a/donut-browser-daemon b/donut-browser-daemon new file mode 100644 index 0000000..e69de29 diff --git a/next-env.d.ts b/next-env.d.ts index b87975d..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./dist/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index b35d52c..494706c 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -111,15 +111,6 @@ pub struct AppUpdateInfo { pub release_page_url: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AppUpdateProgress { - pub stage: String, // "downloading", "extracting", "installing", "completed" - pub percentage: Option, - pub speed: Option, // MB/s - pub eta: Option, // estimated time remaining - pub message: String, -} - pub struct AppAutoUpdater { client: Client, extractor: &'static crate::extraction::Extractor, @@ -688,116 +679,15 @@ impl AppAutoUpdater { None } - /// Download and install app update - pub async fn download_and_install_update( - &self, - app_handle: &tauri::AppHandle, - update_info: &AppUpdateInfo, - ) -> Result<(), Box> { - // Create temporary directory for download - let temp_dir = std::env::temp_dir().join("donut_app_update"); - fs::create_dir_all(&temp_dir)?; - - // Extract filename from URL - let filename = update_info - .download_url - .split('/') - .next_back() - .unwrap_or("update.dmg") - .to_string(); - - // Emit download start event - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "downloading".to_string(), - percentage: Some(0.0), - speed: None, - eta: None, - message: "Starting download...".to_string(), - }, - ); - - // Download the update with progress tracking - let download_path = self - .download_update_with_progress(&update_info.download_url, &temp_dir, &filename, app_handle) - .await?; - - // Emit extraction start event - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "extracting".to_string(), - percentage: None, - speed: None, - eta: None, - message: "Preparing update...".to_string(), - }, - ); - - // Extract the update - let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; - - // Emit installation start event - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "installing".to_string(), - percentage: None, - speed: None, - eta: None, - message: "Installing update...".to_string(), - }, - ); - - // Install the update (overwrite current app) - self.install_update(&extracted_app_path).await?; - - // Clean up temporary files - let _ = fs::remove_dir_all(&temp_dir); - - // Emit completion event - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "completed".to_string(), - percentage: Some(100.0), - speed: None, - eta: None, - message: "Update completed. Restarting...".to_string(), - }, - ); - - // Restart the application - self.restart_application().await?; - - Ok(()) - } - - /// Download the update file with progress tracking - async fn download_update_with_progress( + /// Download the update file without progress tracking (silent download) + async fn download_update_silent( &self, download_url: &str, dest_dir: &Path, filename: &str, - _app_handle: &tauri::AppHandle, ) -> Result> { let file_path = dest_dir.join(filename); - // First, try to get the file size via HEAD request - // This is more reliable than GET content-length for some CDN configurations - // especially when dealing with redirects (like GitHub releases) - let head_size = self - .client - .head(download_url) - .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 - .ok() - .and_then(|r| r.content_length()); - - log::info!("HEAD request for download size: {:?} bytes", head_size); - let response = self .client .get(download_url) @@ -809,80 +699,61 @@ impl AppAutoUpdater { return Err(format!("Download failed with status: {}", response.status()).into()); } - // Use HEAD size if available, otherwise fall back to GET content-length - let total_size = head_size.or(response.content_length()).unwrap_or(0); - log::info!("Final download size: {} bytes", total_size); + let total_size = response.content_length().unwrap_or(0); + log::info!("Silent download size: {} bytes", total_size); let mut file = fs::File::create(&file_path)?; let mut stream = response.bytes_stream(); - let mut downloaded = 0u64; - let start_time = std::time::Instant::now(); - let mut last_update = std::time::Instant::now(); use futures_util::StreamExt; while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - - // Update progress every 100ms to avoid overwhelming the UI - if last_update.elapsed().as_millis() > 100 { - let elapsed = start_time.elapsed().as_secs_f64(); - let percentage = if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }; - - let speed = if elapsed > 0.0 { - downloaded as f64 / elapsed / 1024.0 / 1024.0 // MB/s - } else { - 0.0 - }; - - let eta = if total_size > 0 && speed > 0.0 { - let remaining_bytes = total_size - downloaded; - let remaining_seconds = (remaining_bytes as f64 / 1024.0 / 1024.0) / speed; - if remaining_seconds < 60.0 { - format!("{}s", remaining_seconds as u32) - } else { - let minutes = remaining_seconds as u32 / 60; - let seconds = remaining_seconds as u32 % 60; - format!("{minutes}m {seconds}s") - } - } else { - "Unknown".to_string() - }; - - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "downloading".to_string(), - percentage: Some(percentage), - speed: Some(format!("{speed:.1}")), - eta: Some(eta), - message: "Downloading update...".to_string(), - }, - ); - - last_update = std::time::Instant::now(); - } } - // Emit final download completion - let _ = events::emit( - "app-update-progress", - AppUpdateProgress { - stage: "downloading".to_string(), - percentage: Some(100.0), - speed: None, - eta: None, - message: "Download completed".to_string(), - }, - ); - + log::info!("Silent download completed: {}", file_path.display()); Ok(file_path) } + /// Download and prepare app update (silent download + install + notify) + pub async fn download_and_prepare_update( + &self, + _app_handle: &tauri::AppHandle, + update_info: &AppUpdateInfo, + ) -> Result<(), Box> { + log::info!("Starting background update download and install"); + + let temp_dir = std::env::temp_dir().join("donut_app_update"); + fs::create_dir_all(&temp_dir)?; + + let filename = update_info + .download_url + .split('/') + .next_back() + .unwrap_or("update.dmg") + .to_string(); + + log::info!("Downloading update from: {}", update_info.download_url); + + let download_path = self + .download_update_silent(&update_info.download_url, &temp_dir, &filename) + .await?; + + log::info!("Extracting update..."); + let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; + + log::info!("Installing update (overwriting binary)..."); + self.install_update(&extracted_app_path).await?; + + log::info!("Cleaning up temporary files..."); + let _ = fs::remove_dir_all(&temp_dir); + + log::info!("Update installed successfully, emitting app-update-ready event"); + + let _ = events::emit("app-update-ready", update_info.new_version.clone()); + + Ok(()) + } + /// Extract the update using the extraction module async fn extract_update( &self, @@ -1668,15 +1539,24 @@ pub async fn check_for_app_updates() -> Result, String> { } #[tauri::command] -pub async fn download_and_install_app_update( +pub async fn download_and_prepare_app_update( app_handle: tauri::AppHandle, update_info: AppUpdateInfo, ) -> Result<(), String> { let updater = AppAutoUpdater::instance(); updater - .download_and_install_update(&app_handle, &update_info) + .download_and_prepare_update(&app_handle, &update_info) .await - .map_err(|e| format!("Failed to install app update: {e}")) + .map_err(|e| format!("Failed to download and prepare app update: {e}")) +} + +#[tauri::command] +pub async fn restart_application() -> Result<(), String> { + let updater = AppAutoUpdater::instance(); + updater + .restart_application() + .await + .map_err(|e| format!("Failed to restart application: {e}")) } #[tauri::command] diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs index bfa93a5..f9c55d6 100644 --- a/src-tauri/src/bin/donut_daemon.rs +++ b/src-tauri/src/bin/donut_daemon.rs @@ -8,6 +8,7 @@ use std::fs; use std::path::PathBuf; use std::process; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; use muda::MenuEvent; use serde::{Deserialize, Serialize}; @@ -21,6 +22,14 @@ use donutbrowser_lib::daemon::{autostart, services, tray}; static SHOULD_QUIT: AtomicBool = AtomicBool::new(false); +enum ServiceStatus { + Ready { + api_port: Option, + mcp_running: bool, + }, + Failed(String), +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct DaemonState { daemon_pid: Option, @@ -140,10 +149,24 @@ fn run_daemon() { // Set high priority so the daemon is less likely to be killed under resource pressure set_high_priority(); - // Initialize logging + // Initialize logging to file for debugging (since stdout/stderr may be redirected) + let log_path = autostart::get_data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("daemon.log"); + + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path); + env_logger::Builder::from_default_env() .filter_level(log::LevelFilter::Info) .format_timestamp_millis() + .target(if let Ok(file) = log_file { + env_logger::Target::Pipe(Box::new(file)) + } else { + env_logger::Target::Stderr + }) .init(); if let Err(e) = ensure_data_dir() { @@ -163,22 +186,28 @@ fn run_daemon() { // Create tokio runtime for async operations let rt = Runtime::new().expect("Failed to create tokio runtime"); - // Start services in the background - let services_result = rt.block_on(async { services::DaemonServices::start().await }); + // Create channel for service status updates + let (tx, rx) = mpsc::channel::(); - let daemon_services = match services_result { - Ok(s) => s, - Err(e) => { - log::error!("Failed to start services: {}", e); - process::exit(1); - } - }; + // Spawn services in a background thread so we don't block the event loop + let rt_handle = rt.handle().clone(); + std::thread::spawn(move || { + let result = rt_handle.block_on(async { services::DaemonServices::start().await }); + let status = match result { + Ok(s) => ServiceStatus::Ready { + api_port: s.api_port, + mcp_running: s.mcp_running, + }, + Err(e) => ServiceStatus::Failed(e), + }; + let _ = tx.send(status); + }); - // Write initial state + // Write initial state (services still starting) let state = DaemonState { daemon_pid: Some(process::id()), - api_port: daemon_services.api_port, - mcp_running: daemon_services.mcp_running, + api_port: None, + mcp_running: false, version: env!("CARGO_PKG_VERSION").to_string(), }; if let Err(e) = write_state(&state) { @@ -186,14 +215,15 @@ fn run_daemon() { } // Prepare tray menu and icon (but don't create the tray icon yet) + // Show "Starting..." state initially let tray_menu = tray::TrayMenu::new(); - tray_menu.update_api_status(daemon_services.api_port); - tray_menu.update_mcp_status(daemon_services.mcp_running); + tray_menu.update_api_status(None); + tray_menu.update_mcp_status(false); let icon = tray::load_icon(); let menu_channel = MenuEvent::receiver(); - // Create the event loop + // Create the event loop IMMEDIATELY (critical for macOS tray icon) let event_loop = EventLoopBuilder::new().build(); // Store tray icon in Option - created after event loop starts @@ -222,6 +252,34 @@ fn run_daemon() { log::info!("[daemon] Tray icon created"); } Event::MainEventsCleared => { + // Check for service status updates from background thread + if let Ok(status) = rx.try_recv() { + match status { + ServiceStatus::Ready { + api_port, + mcp_running, + } => { + log::info!("[daemon] Services started successfully"); + tray_menu.update_api_status(api_port); + tray_menu.update_mcp_status(mcp_running); + + // Update state file + let mut state = read_state(); + state.api_port = api_port; + state.mcp_running = mcp_running; + if let Err(e) = write_state(&state) { + log::error!("Failed to write state: {}", e); + } + } + ServiceStatus::Failed(e) => { + log::error!("Failed to start services: {}", e); + // Keep tray icon running, show error state + tray_menu.update_api_status(None); + tray_menu.update_mcp_status(false); + } + } + } + // Process menu events while let Ok(event) = menu_channel.try_recv() { if event.id == tray_menu.open_item.id() || event.id == tray_menu.preferences_item.id() { @@ -246,6 +304,9 @@ fn run_daemon() { // Keep tray_icon alive let _ = &tray_icon; + + // Keep runtime alive + let _ = &rt; }); } diff --git a/src-tauri/src/daemon_spawn.rs b/src-tauri/src/daemon_spawn.rs new file mode 100644 index 0000000..f966e82 --- /dev/null +++ b/src-tauri/src/daemon_spawn.rs @@ -0,0 +1,204 @@ +// Daemon Spawn - Start the daemon from the GUI + +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +use crate::daemon::autostart; + +#[derive(Debug, Deserialize, Default)] +struct DaemonState { + daemon_pid: Option, +} + +fn get_state_path() -> PathBuf { + autostart::get_data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("daemon-state.json") +} + +fn read_state() -> DaemonState { + let path = get_state_path(); + if path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(state) = serde_json::from_str(&content) { + return state; + } + } + } + DaemonState::default() +} + +fn is_daemon_running() -> bool { + let state = read_state(); + + if let Some(pid) = state.daemon_pid { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + + #[cfg(windows)] + { + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid)]) + .output(); + output + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .unwrap_or(false) + } + + #[cfg(not(any(unix, windows)))] + { + false + } + } else { + false + } +} + +fn get_daemon_path() -> Option { + // First, try to find it next to the current executable + if let Ok(current_exe) = std::env::current_exe() { + let exe_dir = current_exe.parent()?; + + // Check for daemon binary in same directory + #[cfg(target_os = "windows")] + let daemon_name = "donut-daemon.exe"; + #[cfg(not(target_os = "windows"))] + let daemon_name = "donut-daemon"; + + let daemon_path = exe_dir.join(daemon_name); + if daemon_path.exists() { + return Some(daemon_path); + } + + // On macOS, check inside the app bundle + #[cfg(target_os = "macos")] + { + // If we're in Contents/MacOS, daemon should be there too + if exe_dir.ends_with("Contents/MacOS") { + let daemon_path = exe_dir.join(daemon_name); + if daemon_path.exists() { + return Some(daemon_path); + } + } + } + } + + // Try to find it in PATH + #[cfg(target_os = "windows")] + { + if let Ok(output) = Command::new("where").arg("donut-daemon").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout); + let path = path.lines().next()?.trim(); + return Some(PathBuf::from(path)); + } + } + } + + #[cfg(unix)] + { + if let Ok(output) = Command::new("which").arg("donut-daemon").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout); + let path = path.trim(); + if !path.is_empty() { + return Some(PathBuf::from(path)); + } + } + } + } + + None +} + +pub fn spawn_daemon() -> Result<(), String> { + // Check if already running + if is_daemon_running() { + log::info!("Daemon is already running"); + return Ok(()); + } + + // Log current exe location for debugging + let current_exe = std::env::current_exe().ok(); + log::info!("Current exe: {:?}", current_exe); + + let daemon_path = get_daemon_path().ok_or_else(|| { + format!( + "Could not find daemon binary. Current exe: {:?}", + current_exe + ) + })?; + + log::info!("Spawning daemon from: {:?}", daemon_path); + + // Use "run" instead of "start" - we handle detachment here + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + // Create a new process group so daemon survives parent exit + // Note: We don't call setsid() because on macOS that disconnects from the WindowServer + // which prevents the tray icon from appearing. Instead, we just set a new process group. + let mut cmd = Command::new(&daemon_path); + cmd + .arg("run") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); // Create new process group without new session + + cmd + .spawn() + .map_err(|e| format!("Failed to spawn daemon: {}", e))?; + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + + Command::new(&daemon_path) + .arg("run") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|e| format!("Failed to spawn daemon: {}", e))?; + } + + // Wait for daemon to start (max 3 seconds) + for i in 0..30 { + thread::sleep(Duration::from_millis(100)); + if is_daemon_running() { + log::info!("Daemon started successfully after {}ms", (i + 1) * 100); + return Ok(()); + } + } + + // Check if we got a state file at least + let state = read_state(); + if state.daemon_pid.is_some() { + log::info!( + "Daemon appears to have started (PID {} in state file)", + state.daemon_pid.unwrap() + ); + return Ok(()); + } + + Err("Daemon did not start within timeout".to_string()) +} + +pub fn ensure_daemon_running() -> Result<(), String> { + if !is_daemon_running() { + spawn_daemon()?; + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 942876c..035d218 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ mod commercial_license; mod cookie_manager; pub mod daemon; pub mod daemon_client; +mod daemon_spawn; pub mod daemon_ws; pub mod events; mod mcp_server; @@ -70,8 +71,9 @@ use downloaded_browsers_registry::{ use downloader::download_browser; use settings_manager::{ - get_app_settings, get_sync_settings, get_table_sorting_settings, save_app_settings, - save_sync_settings, save_table_sorting_settings, should_show_settings_on_startup, + decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings, + get_table_sorting_settings, save_app_settings, save_sync_settings, save_table_sorting_settings, + should_show_launch_on_login_prompt, }; use sync::{ @@ -93,7 +95,8 @@ use auto_updater::{ }; use app_auto_updater::{ - check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, + check_for_app_updates, check_for_app_updates_manual, download_and_prepare_app_update, + restart_application, }; use profile_importer::{detect_existing_profiles, import_browser_profile}; @@ -428,6 +431,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_macos_permissions::init()) .setup(|app| { + // Start the daemon for tray icon + if let Err(e) = daemon_spawn::ensure_daemon_running() { + log::warn!("Failed to start daemon: {e}"); + } + // Create the main window programmatically #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) @@ -894,7 +902,9 @@ pub fn run() { rename_profile, get_app_settings, save_app_settings, - should_show_settings_on_startup, + should_show_launch_on_login_prompt, + enable_launch_on_login, + decline_launch_on_login, get_table_sorting_settings, save_table_sorting_settings, clear_all_version_cache_and_refetch, @@ -908,7 +918,8 @@ pub fn run() { complete_browser_update_with_auto_update, check_for_app_updates, check_for_app_updates_manual, - download_and_install_app_update, + download_and_prepare_app_update, + restart_application, detect_existing_profiles, import_browser_profile, check_missing_binaries, diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 4f0ea4b..b03cdc8 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -50,6 +50,8 @@ pub struct AppSettings { pub mcp_port: Option, // Port for MCP server (default 51080) #[serde(default)] pub mcp_token: Option, // Displayed token for user to copy (not persisted, loaded from encrypted file) + #[serde(default)] + pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -81,6 +83,7 @@ impl Default for AppSettings { mcp_enabled: false, mcp_port: None, mcp_token: None, + launch_on_login_declined: false, } } } @@ -206,9 +209,17 @@ impl SettingsManager { Ok(()) } - pub fn should_show_settings_on_startup(&self) -> Result> { - // Always return false - we don't show settings on startup anymore - Ok(false) + pub fn should_show_launch_on_login_prompt(&self) -> Result> { + let settings = self.load_settings()?; + // Show if: user has NOT declined AND autostart is NOT enabled + let autostart_enabled = crate::daemon::autostart::is_autostart_enabled(); + Ok(!settings.launch_on_login_declined && !autostart_enabled) + } + + pub fn decline_launch_on_login(&self) -> Result<(), Box> { + let mut settings = self.load_settings()?; + settings.launch_on_login_declined = true; + self.save_settings(&settings) } fn get_vault_password() -> String { @@ -806,11 +817,25 @@ pub async fn save_app_settings( } #[tauri::command] -pub async fn should_show_settings_on_startup() -> Result { +pub async fn should_show_launch_on_login_prompt() -> Result { let manager = SettingsManager::instance(); manager - .should_show_settings_on_startup() - .map_err(|e| format!("Failed to check prompt setting: {e}")) + .should_show_launch_on_login_prompt() + .map_err(|e| format!("Failed to check launch on login prompt setting: {e}")) +} + +#[tauri::command] +pub async fn enable_launch_on_login() -> Result<(), String> { + crate::daemon::autostart::enable_autostart() + .map_err(|e| format!("Failed to enable autostart: {e}")) +} + +#[tauri::command] +pub async fn decline_launch_on_login() -> Result<(), String> { + let manager = SettingsManager::instance(); + manager + .decline_launch_on_login() + .map_err(|e| format!("Failed to decline launch on login: {e}")) } #[tauri::command] @@ -959,6 +984,7 @@ mod tests { mcp_enabled: false, mcp_port: None, mcp_token: None, + launch_on_login_declined: false, }; // Save settings @@ -1024,17 +1050,31 @@ mod tests { } #[test] - fn test_should_show_settings_on_startup() { + fn test_should_show_launch_on_login_prompt() { let (manager, _temp_dir) = create_test_settings_manager(); - let result = manager.should_show_settings_on_startup(); + let result = manager.should_show_launch_on_login_prompt(); assert!(result.is_ok(), "Should not fail"); - let should_show = result.unwrap(); - assert!( - !should_show, - "Should always return false as per implementation" - ); + // By default, should show prompt (not declined, autostart not enabled) + let _should_show = result.unwrap(); + // Note: The actual value depends on system autostart state, so we just test it doesn't fail + } + + #[test] + fn test_decline_launch_on_login() { + let (manager, _temp_dir) = create_test_settings_manager(); + + // Initially not declined + let settings = manager.load_settings().unwrap(); + assert!(!settings.launch_on_login_declined); + + // Decline + manager.decline_launch_on_login().unwrap(); + + // Should be declined now + let settings = manager.load_settings().unwrap(); + assert!(settings.launch_on_login_declined); } #[test] diff --git a/src/app/page.tsx b/src/app/page.tsx index cb4a201..56d1011 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog"; import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { IntegrationsDialog } from "@/components/integrations-dialog"; +import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; @@ -118,6 +119,7 @@ export default function Home() { const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] = useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); + const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [currentPermissionType, setCurrentPermissionType] = useState("microphone"); @@ -268,10 +270,10 @@ export default function Home() { try { const shouldShow = await invoke( - "should_show_settings_on_startup", + "should_show_launch_on_login_prompt", ); if (shouldShow) { - setSettingsDialogOpen(true); + setLaunchOnLoginDialogOpen(true); } } catch (error) { console.error("Failed to check startup prompt:", error); @@ -1040,6 +1042,12 @@ export default function Home() { } onClose={checkTrialStatus} /> + + {/* Launch on Login Dialog - shown on every startup until enabled or declined */} + setLaunchOnLoginDialogOpen(false)} + /> ); } diff --git a/src/components/app-update-toast.tsx b/src/components/app-update-toast.tsx index 1307216..58e4cbf 100644 --- a/src/components/app-update-toast.tsx +++ b/src/components/app-update-toast.tsx @@ -10,9 +10,11 @@ import { RippleButton } from "./ui/ripple"; interface AppUpdateToastProps { updateInfo: AppUpdateInfo; onUpdate: (updateInfo: AppUpdateInfo) => Promise; + onRestart: () => Promise; onDismiss: () => void; isUpdating?: boolean; updateProgress?: AppUpdateProgress | null; + updateReady?: boolean; } function getStageIcon(stage?: string, isUpdating?: boolean) { @@ -52,17 +54,22 @@ function getStageDisplayName(stage?: string) { export function AppUpdateToast({ updateInfo, onUpdate, + onRestart, onDismiss, isUpdating = false, updateProgress, + updateReady = false, }: AppUpdateToastProps) { const handleUpdateClick = async () => { await onUpdate(updateInfo); }; + const handleRestartClick = async () => { + await onRestart(); + }; + const handleViewRelease = () => { if (updateInfo.release_page_url) { - // Trigger the same URL handling logic as if the URL came from the system const event = new CustomEvent("url-open-request", { detail: updateInfo.release_page_url, }); @@ -85,7 +92,11 @@ export function AppUpdateToast({ return (
- {getStageIcon(updateProgress?.stage, isUpdating)} + {updateReady ? ( + + ) : ( + getStageIcon(updateProgress?.stage, isUpdating) + )}
@@ -93,35 +104,43 @@ export function AppUpdateToast({
- {isUpdating - ? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update` - : "Donut Browser Update Available"} + {updateReady + ? "The update is ready, restart app" + : isUpdating + ? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update` + : "Donut Browser Update Available"} - - {updateInfo.is_nightly ? "Nightly" : "Stable"} - -
-
- {isUpdating ? ( - updateProgress?.message || "Updating..." - ) : ( - <> - Update from {updateInfo.current_version} to{" "} - {updateInfo.new_version} - {updateInfo.manual_update_required && ( - - Manual download required on Linux - - )} - + {!updateReady && ( + + {updateInfo.is_nightly ? "Nightly" : "Stable"} + )}
+ {!updateReady && ( +
+ {isUpdating ? ( + updateProgress?.message || "Updating..." + ) : ( + <> + Update from {updateInfo.current_version} to{" "} + + {updateInfo.new_version} + + {updateInfo.manual_update_required && ( + + Manual download required on Linux + + )} + + )} +
+ )}
- {!isUpdating && ( + {!isUpdating && !updateReady && ( + + Enable + + + + + ); +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index cb15ae3..ad2f6d3 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -38,7 +38,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { ) { setDefaultTheme(themeValue); } else if (themeValue === "custom") { - setDefaultTheme("light"); + setDefaultTheme("dark"); if ( settings.custom_theme && Object.keys(settings.custom_theme).length > 0 diff --git a/src/hooks/use-app-update-notifications.tsx b/src/hooks/use-app-update-notifications.tsx index c0587ca..1b70c4a 100644 --- a/src/hooks/use-app-update-notifications.tsx +++ b/src/hooks/use-app-update-notifications.tsx @@ -13,6 +13,7 @@ export function useAppUpdateNotifications() { const [isUpdating, setIsUpdating] = useState(false); const [updateProgress, setUpdateProgress] = useState(null); + const [updateReady, setUpdateReady] = useState(false); const [isClient, setIsClient] = useState(false); const [dismissedVersion, setDismissedVersion] = useState(null); @@ -68,7 +69,7 @@ export function useAppUpdateNotifications() { message: "Starting update...", }); - await invoke("download_and_install_app_update", { + await invoke("download_and_prepare_app_update", { updateInfo: appUpdateInfo, }); } catch (error) { @@ -84,6 +85,20 @@ export function useAppUpdateNotifications() { } }, []); + const handleRestart = useCallback(async () => { + try { + await invoke("restart_application"); + } catch (error) { + console.error("Failed to restart app:", error); + showToast({ + type: "error", + title: "Failed to restart", + description: String(error), + duration: 6000, + }); + } + }, []); + const dismissAppUpdate = useCallback(() => { if (!isClient) return; @@ -125,6 +140,13 @@ export function useAppUpdateNotifications() { }, ); + const unlistenReady = listen("app-update-ready", (event) => { + console.log("App update ready:", event.payload); + setUpdateReady(true); + setIsUpdating(false); + setUpdateProgress(null); + }); + return () => { void unlistenUpdate.then((unlisten) => { unlisten(); @@ -132,6 +154,9 @@ export function useAppUpdateNotifications() { void unlistenProgress.then((unlisten) => { unlisten(); }); + void unlistenReady.then((unlisten) => { + unlisten(); + }); }; }, [isClient]); @@ -144,9 +169,11 @@ export function useAppUpdateNotifications() { ), { @@ -163,9 +190,11 @@ export function useAppUpdateNotifications() { }, [ updateInfo, handleAppUpdate, + handleRestart, dismissAppUpdate, isUpdating, updateProgress, + updateReady, isClient, ]); @@ -181,6 +210,7 @@ export function useAppUpdateNotifications() { updateInfo, isUpdating, updateProgress, + updateReady, checkForAppUpdates, checkForAppUpdatesManual, dismissAppUpdate,