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
+5
View File
@@ -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"
View File
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+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]
+10 -2
View File
@@ -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<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("microphone");
@@ -268,10 +270,10 @@ export default function Home() {
try {
const shouldShow = await invoke<boolean>(
"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 */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => setLaunchOnLoginDialogOpen(false)}
/>
</div>
);
}
+84 -55
View File
@@ -10,9 +10,11 @@ import { RippleButton } from "./ui/ripple";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
onRestart: () => Promise<void>;
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 (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
{getStageIcon(updateProgress?.stage, isUpdating)}
{updateReady ? (
<LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />
) : (
getStageIcon(updateProgress?.stage, isUpdating)
)}
</div>
<div className="flex-1 min-w-0">
@@ -93,35 +104,43 @@ export function AppUpdateToast({
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="text-sm font-semibold text-foreground">
{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"}
</span>
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
className="text-xs"
>
{updateInfo.is_nightly ? "Nightly" : "Stable"}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{isUpdating ? (
updateProgress?.message || "Updating..."
) : (
<>
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
{updateInfo.manual_update_required && (
<span className="block mt-1 text-muted-foreground/80">
Manual download required on Linux
</span>
)}
</>
{!updateReady && (
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
className="text-xs"
>
{updateInfo.is_nightly ? "Nightly" : "Stable"}
</Badge>
)}
</div>
{!updateReady && (
<div className="text-xs text-muted-foreground">
{isUpdating ? (
updateProgress?.message || "Updating..."
) : (
<>
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">
{updateInfo.new_version}
</span>
{updateInfo.manual_update_required && (
<span className="block mt-1 text-muted-foreground/80">
Manual download required on Linux
</span>
)}
</>
)}
</div>
)}
</div>
{!isUpdating && (
{!isUpdating && !updateReady && (
<Button
variant="ghost"
size="sm"
@@ -133,8 +152,7 @@ export function AppUpdateToast({
)}
</div>
{/* Download progress */}
{showDownloadProgress && updateProgress && (
{!updateReady && showDownloadProgress && updateProgress && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
@@ -152,10 +170,8 @@ export function AppUpdateToast({
</div>
)}
{/* Other stage progress (with visual indicators) */}
{showOtherStageProgress && (
{!updateReady && showOtherStageProgress && (
<div className="mt-2 space-y-1">
{/* Progress indicator for non-downloading stages */}
<div className="w-full bg-muted rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-500 ${
@@ -168,36 +184,49 @@ export function AppUpdateToast({
</div>
)}
{!isUpdating && (
{updateReady ? (
<div className="flex gap-2 items-center mt-3">
{updateInfo.manual_update_required ? (
<RippleButton
onClick={handleViewRelease}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
View Release
</RippleButton>
) : (
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Update Now
</RippleButton>
)}
<RippleButton
variant="outline"
onClick={onDismiss}
onClick={() => void handleRestartClick()}
size="sm"
className="text-xs"
className="flex gap-2 items-center text-xs"
>
Later
<LuCheckCheck className="w-3 h-3" />
Restart Now
</RippleButton>
</div>
) : (
!isUpdating && (
<div className="flex gap-2 items-center mt-3">
{updateInfo.manual_update_required ? (
<RippleButton
onClick={handleViewRelease}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
View Release
</RippleButton>
) : (
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Download Update
</RippleButton>
)}
<RippleButton
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</RippleButton>
</div>
)
)}
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface LaunchOnLoginDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LaunchOnLoginDialog({
isOpen,
onClose,
}: LaunchOnLoginDialogProps) {
const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleEnable = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_launch_on_login");
showSuccessToast("Launch on login enabled");
onClose();
} catch (error) {
console.error("Failed to enable launch on login:", error);
showErrorToast("Failed to enable launch on login", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsEnabling(false);
}
}, [onClose]);
const handleDecline = useCallback(async () => {
setIsDeclining(true);
try {
await invoke("decline_launch_on_login");
onClose();
} catch (error) {
console.error("Failed to decline launch on login:", error);
showErrorToast("Failed to save preference", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsDeclining(false);
}
}, [onClose]);
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Enable Launch on Login?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Running in the background helps keep your proxies and browsers alive.
</p>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="ghost"
onClick={handleDecline}
disabled={isEnabling || isDeclining}
>
{isDeclining ? "..." : "Don't Ask Again"}
</Button>
<LoadingButton
onClick={handleEnable}
isLoading={isEnabling}
disabled={isDeclining}
>
Enable
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -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
+31 -1
View File
@@ -13,6 +13,7 @@ export function useAppUpdateNotifications() {
const [isUpdating, setIsUpdating] = useState(false);
const [updateProgress, setUpdateProgress] =
useState<AppUpdateProgress | null>(null);
const [updateReady, setUpdateReady] = useState(false);
const [isClient, setIsClient] = useState(false);
const [dismissedVersion, setDismissedVersion] = useState<string | null>(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<string>("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() {
<AppUpdateToast
updateInfo={updateInfo}
onUpdate={handleAppUpdate}
onRestart={handleRestart}
onDismiss={dismissAppUpdate}
isUpdating={isUpdating}
updateProgress={updateProgress}
updateReady={updateReady}
/>
),
{
@@ -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,