mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-06 21:17:50 +02:00
feat: move background processes to its own daemon
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
// Donut Browser Daemon - Background process for tray icon and services
|
||||
// This runs independently of the main Tauri GUI
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use muda::MenuEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use single_instance::SingleInstance;
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
use tray_icon::TrayIcon;
|
||||
|
||||
use donutbrowser_lib::daemon::{autostart, services, tray};
|
||||
|
||||
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct DaemonState {
|
||||
daemon_pid: Option<u32>,
|
||||
api_port: Option<u16>,
|
||||
mcp_running: bool,
|
||||
version: String,
|
||||
}
|
||||
|
||||
fn get_state_path() -> PathBuf {
|
||||
autostart::get_data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("daemon-state.json")
|
||||
}
|
||||
|
||||
fn ensure_data_dir() -> std::io::Result<()> {
|
||||
if let Some(data_dir) = autostart::get_data_dir() {
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 write_state(state: &DaemonState) -> std::io::Result<()> {
|
||||
let path = get_state_path();
|
||||
let content = serde_json::to_string_pretty(state)?;
|
||||
fs::write(path, content)
|
||||
}
|
||||
|
||||
fn detach_from_parent() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::setsid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_detached() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
match unsafe { libc::fork() } {
|
||||
-1 => {
|
||||
eprintln!("Fork failed");
|
||||
process::exit(1);
|
||||
}
|
||||
0 => {
|
||||
detach_from_parent();
|
||||
}
|
||||
_ => {
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
let current_exe = env::current_exe().expect("Failed to get current exe path");
|
||||
|
||||
let _ = Command::new(current_exe)
|
||||
.arg("--daemon-internal")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
||||
.spawn();
|
||||
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_high_priority() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Set high priority so the daemon is killed last under resource pressure
|
||||
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
|
||||
unsafe {
|
||||
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
|
||||
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
|
||||
};
|
||||
|
||||
// Set high priority so the daemon is killed last under resource pressure
|
||||
unsafe {
|
||||
let handle = GetCurrentProcess();
|
||||
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
|
||||
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
|
||||
// but we do it anyway for consistency
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_daemon() {
|
||||
// Set high priority so the daemon is less likely to be killed under resource pressure
|
||||
set_high_priority();
|
||||
|
||||
// Initialize logging
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
if let Err(e) = ensure_data_dir() {
|
||||
eprintln!("Failed to create data directory: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let instance =
|
||||
SingleInstance::new("donut-browser-daemon").expect("Failed to create single instance lock");
|
||||
if !instance.is_single() {
|
||||
eprintln!("Daemon is already running");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("[daemon] Starting with PID {}", process::id());
|
||||
|
||||
// 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 });
|
||||
|
||||
let daemon_services = match services_result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to start services: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Write initial state
|
||||
let state = DaemonState {
|
||||
daemon_pid: Some(process::id()),
|
||||
api_port: daemon_services.api_port,
|
||||
mcp_running: daemon_services.mcp_running,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
};
|
||||
if let Err(e) = write_state(&state) {
|
||||
log::error!("Failed to write state: {}", e);
|
||||
}
|
||||
|
||||
// Prepare tray menu and icon (but don't create the tray icon yet)
|
||||
let tray_menu = tray::TrayMenu::new();
|
||||
tray_menu.update_api_status(daemon_services.api_port);
|
||||
tray_menu.update_mcp_status(daemon_services.mcp_running);
|
||||
|
||||
let icon = tray::load_icon();
|
||||
let menu_channel = MenuEvent::receiver();
|
||||
|
||||
// Create the event loop
|
||||
let event_loop = EventLoopBuilder::new().build();
|
||||
|
||||
// Store tray icon in Option - created after event loop starts
|
||||
let mut tray_icon: Option<TrayIcon> = None;
|
||||
|
||||
// Run the event loop
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Poll;
|
||||
|
||||
match event {
|
||||
Event::NewEvents(StartCause::Init) => {
|
||||
// Hide from dock on macOS (must be done after event loop starts)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
||||
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
}
|
||||
}
|
||||
|
||||
// Create tray icon after event loop has started (required for macOS)
|
||||
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
|
||||
log::info!("[daemon] Tray icon created");
|
||||
}
|
||||
Event::MainEventsCleared => {
|
||||
// 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() {
|
||||
tray::open_gui();
|
||||
} else if event.id == tray_menu.quit_item.id() {
|
||||
log::info!("[daemon] Quit requested");
|
||||
SHOULD_QUIT.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
if SHOULD_QUIT.load(Ordering::SeqCst) {
|
||||
// Cleanup
|
||||
let mut state = read_state();
|
||||
state.daemon_pid = None;
|
||||
let _ = write_state(&state);
|
||||
log::info!("[daemon] Exiting");
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Keep tray_icon alive
|
||||
let _ = &tray_icon;
|
||||
});
|
||||
}
|
||||
|
||||
fn stop_daemon() {
|
||||
let state = read_state();
|
||||
|
||||
if let Some(pid) = state.daemon_pid {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Daemon is not running");
|
||||
}
|
||||
}
|
||||
|
||||
fn show_status() {
|
||||
let state = read_state();
|
||||
|
||||
if let Some(pid) = state.daemon_pid {
|
||||
#[cfg(unix)]
|
||||
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
|
||||
|
||||
#[cfg(windows)]
|
||||
let is_running = {
|
||||
use std::process::Command;
|
||||
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)))]
|
||||
let is_running = false;
|
||||
|
||||
if is_running {
|
||||
eprintln!("Daemon is running (PID {})", pid);
|
||||
if let Some(port) = state.api_port {
|
||||
eprintln!(" API: Running on port {}", port);
|
||||
} else {
|
||||
eprintln!(" API: Stopped");
|
||||
}
|
||||
eprintln!(
|
||||
" MCP: {}",
|
||||
if state.mcp_running {
|
||||
"Running"
|
||||
} else {
|
||||
"Stopped"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
eprintln!("Daemon is not running (stale PID in state file)");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Daemon is not running");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("Donut Browser Daemon");
|
||||
eprintln!();
|
||||
eprintln!("Usage: donut-daemon <command>");
|
||||
eprintln!();
|
||||
eprintln!("Commands:");
|
||||
eprintln!(" start Start the daemon (detaches from terminal)");
|
||||
eprintln!(" stop Stop the running daemon");
|
||||
eprintln!(" status Show daemon status");
|
||||
eprintln!(" run Run in foreground (for debugging)");
|
||||
eprintln!(" autostart Manage autostart settings");
|
||||
eprintln!(" enable Enable autostart on login");
|
||||
eprintln!(" disable Disable autostart on login");
|
||||
eprintln!(" status Show autostart status");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
print_usage();
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
match args[1].as_str() {
|
||||
"start" => {
|
||||
eprintln!("Starting daemon...");
|
||||
spawn_detached();
|
||||
run_daemon();
|
||||
}
|
||||
"stop" => {
|
||||
stop_daemon();
|
||||
}
|
||||
"status" => {
|
||||
show_status();
|
||||
}
|
||||
"run" => {
|
||||
run_daemon();
|
||||
}
|
||||
"--daemon-internal" => {
|
||||
run_daemon();
|
||||
}
|
||||
"autostart" => {
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
|
||||
process::exit(1);
|
||||
}
|
||||
match args[2].as_str() {
|
||||
"enable" => {
|
||||
if let Err(e) = autostart::enable_autostart() {
|
||||
eprintln!("Failed to enable autostart: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
eprintln!("Autostart enabled");
|
||||
}
|
||||
"disable" => {
|
||||
if let Err(e) = autostart::disable_autostart() {
|
||||
eprintln!("Failed to disable autostart: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
eprintln!("Autostart disabled");
|
||||
}
|
||||
"status" => {
|
||||
if autostart::is_autostart_enabled() {
|
||||
eprintln!("Autostart is enabled");
|
||||
} else {
|
||||
eprintln!("Autostart is disabled");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Unknown autostart command: {}", args[2]);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
print_usage();
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user