feat: move background processes to its own daemon

This commit is contained in:
zhom
2026-01-11 21:01:09 +04:00
parent 6756f88955
commit eeea15c65d
39 changed files with 3466 additions and 948 deletions
+247
View File
@@ -0,0 +1,247 @@
use directories::ProjectDirs;
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/donut-daemon.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/donut-daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents/com.donutbrowser.daemon.plist");
if plist_path.exists() {
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
dirs::home_dir()
.map(|h| {
h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist")
.exists()
})
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec={} start
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
daemon_path.display()
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" start", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod autostart;
pub mod services;
pub mod tray;
+51
View File
@@ -0,0 +1,51 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
+150
View File
@@ -0,0 +1,150 @@
use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn load_icon() -> Icon {
let icon_bytes = include_bytes!("../../icons/32x32.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub open_item: MenuItem,
pub running_profiles_submenu: Submenu,
pub api_status_item: MenuItem,
pub mcp_status_item: MenuItem,
pub preferences_item: MenuItem,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let open_item = MenuItem::new("Open Donut Browser", true, None);
let running_profiles_submenu = Submenu::new("Running Profiles", true);
let no_profiles_item = MenuItem::new("No running profiles", false, None);
running_profiles_submenu.append(&no_profiles_item).unwrap();
let separator1 = PredefinedMenuItem::separator();
let api_status_item = MenuItem::new("API: Starting...", false, None);
let mcp_status_item = MenuItem::new("MCP: Starting...", false, None);
let separator2 = PredefinedMenuItem::separator();
let preferences_item = MenuItem::new("Preferences...", true, None);
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&open_item).unwrap();
menu.append(&running_profiles_submenu).unwrap();
menu.append(&separator1).unwrap();
menu.append(&api_status_item).unwrap();
menu.append(&mcp_status_item).unwrap();
menu.append(&separator2).unwrap();
menu.append(&preferences_item).unwrap();
menu.append(&quit_item).unwrap();
Self {
menu,
open_item,
running_profiles_submenu,
api_status_item,
mcp_status_item,
preferences_item,
quit_item,
}
}
pub fn update_api_status(&self, port: Option<u16>) {
let text = match port {
Some(p) => format!("API: Running on :{}", p),
None => "API: Stopped".to_string(),
};
self.api_status_item.set_text(&text);
}
pub fn update_mcp_status(&self, running: bool) {
let text = if running {
"MCP: Running"
} else {
"MCP: Stopped"
};
self.mcp_status_item.set_text(text);
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()))
.build()
.expect("Failed to create tray icon")
}
pub fn open_gui() {
if GUI_RUNNING.load(Ordering::SeqCst) {
log::info!("GUI already running, activating...");
activate_gui();
return;
}
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn();
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
pub fn activate_gui() {
#[cfg(target_os = "macos")]
{
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut Browser\" to activate"])
.spawn();
}
}
pub fn set_gui_running(running: bool) {
GUI_RUNNING.store(running, Ordering::SeqCst);
}