refactor: auto-update in background

This commit is contained in:
zhom
2026-01-18 01:34:07 +04:00
parent df460f9ab7
commit e9f4edd120
13 changed files with 635 additions and 271 deletions
+57 -177
View File
@@ -111,15 +111,6 @@ pub struct AppUpdateInfo {
pub release_page_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppUpdateProgress {
pub stage: String, // "downloading", "extracting", "installing", "completed"
pub percentage: Option<f64>,
pub speed: Option<String>, // MB/s
pub eta: Option<String>, // 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<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<Option<AppUpdateInfo>, 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]
+77 -16
View File
@@ -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<u16>,
mcp_running: bool,
},
Failed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
@@ -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::<ServiceStatus>();
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;
});
}
+204
View File
@@ -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<u32>,
}
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<PathBuf> {
// 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(())
}
+16 -5
View File
@@ -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,
+53 -13
View File
@@ -50,6 +50,8 @@ pub struct AppSettings {
pub mcp_port: Option<u16>, // Port for MCP server (default 51080)
#[serde(default)]
pub mcp_token: Option<String>, // 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<bool, Box<dyn std::error::Error>> {
// Always return false - we don't show settings on startup anymore
Ok(false)
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<bool, String> {
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
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]