mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-25 09:44:09 +02:00
feat: move background processes to its own daemon
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod autostart;
|
||||
pub mod services;
|
||||
pub mod tray;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user