From 59d6f0d47e2ffecdebb42d512cc2b8aa0182af69 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 4 Dec 2025 18:10:24 -0800 Subject: [PATCH] Add daemon service management commands --- .gitignore | 1 + apps/tauri/src-tauri/src/main.rs | 329 ++++++++++++++++++ apps/tauri/src/platform.ts | 12 + core/src/infra/db/mod.rs | 17 +- .../components/DaemonDisconnectedOverlay.tsx | 69 +++- .../Explorer/views/GridView/GridView.tsx | 12 +- .../interface/src/hooks/useDaemonStatus.ts | 54 ++- packages/interface/src/platform.tsx | 9 + 8 files changed, 474 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index eaf3834a4..33983bc75 100644 --- a/.gitignore +++ b/.gitignore @@ -481,3 +481,4 @@ whitepaper/*.log test_data +:memory: diff --git a/apps/tauri/src-tauri/src/main.rs b/apps/tauri/src-tauri/src/main.rs index 4a0aea62f..1909f1adb 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -107,6 +107,13 @@ impl DaemonConnectionPool { } } + async fn reset(&self) { + let mut initialized = self.initialized.lock().await; + *initialized = false; + *self.writer.lock().await = None; + tracing::info!("Connection pool reset"); + } + async fn ensure_connected(&self, app: &AppHandle) -> Result<(), String> { let mut initialized = self.initialized.lock().await; @@ -787,6 +794,325 @@ async fn stop_daemon_process( Ok(()) } +/// Check if daemon is installed as a service (LaunchAgent on macOS, systemd on Linux) +#[tauri::command] +async fn check_daemon_installed() -> Result { + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let plist_path = std::path::PathBuf::from(home).join("Library/LaunchAgents/com.spacedrive.daemon.plist"); + let exists = plist_path.exists(); + tracing::info!("Checking daemon installation at {}: {}", plist_path.display(), exists); + Ok(exists) + } + + #[cfg(target_os = "linux")] + { + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let service_path = std::path::PathBuf::from(home).join(".config/systemd/user/spacedrive-daemon.service"); + Ok(service_path.exists()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Ok(false) + } +} + +/// Install daemon as a service (LaunchAgent on macOS, systemd on Linux) +#[tauri::command] +async fn install_daemon_service( + app: tauri::AppHandle, + daemon_state: tauri::State<'_, Arc>>, + app_state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let (data_dir, socket_addr) = { + let state = daemon_state.read().await; + (state.data_dir.clone(), state.socket_addr.clone()) + }; + + tracing::info!("Installing daemon as service"); + + // Stop any existing daemon child process first + { + let mut state = daemon_state.write().await; + if let Some(process_arc) = state.daemon_process.take() { + tracing::info!("Stopping existing daemon child process"); + let mut process_lock = process_arc.lock().await; + if let Some(mut child) = process_lock.take() { + let _ = child.kill(); + } + } + } + + // Emit starting event since installation starts the daemon + let _ = app.emit("daemon-starting", ()); + tracing::info!("Emitted daemon-starting event"); + + #[cfg(target_os = "macos")] + { + use std::io::Write; + + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let launch_agents_dir = std::path::PathBuf::from(&home).join("Library/LaunchAgents"); + + std::fs::create_dir_all(&launch_agents_dir) + .map_err(|e| format!("Failed to create LaunchAgents directory: {}", e))?; + + let plist_path = launch_agents_dir.join("com.spacedrive.daemon.plist"); + tracing::info!("Creating plist at: {}", plist_path.display()); + + let daemon_path = std::env::current_exe() + .map_err(|e| format!("Failed to get current exe: {}", e))? + .parent() + .ok_or_else(|| "Could not determine binary directory".to_string())? + .join("sd-daemon"); + + if !daemon_path.exists() { + return Err(format!("Daemon binary not found at {}", daemon_path.display())); + } + + let log_dir = data_dir.join("logs"); + std::fs::create_dir_all(&log_dir) + .map_err(|e| format!("Failed to create logs directory: {}", e))?; + + let plist_content = format!( + r#" + + + + Label + com.spacedrive.daemon + ProgramArguments + + {} + --data-dir + {} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + StandardOutPath + {} + StandardErrorPath + {} + +"#, + daemon_path.display(), + data_dir.display(), + log_dir.join("daemon.out.log").display(), + log_dir.join("daemon.err.log").display() + ); + + let mut file = std::fs::File::create(&plist_path) + .map_err(|e| format!("Failed to create plist file: {}", e))?; + file.write_all(plist_content.as_bytes()) + .map_err(|e| format!("Failed to write plist file: {}", e))?; + + // Unload any existing service first + tracing::info!("Unloading any existing service"); + let _ = std::process::Command::new("launchctl") + .args(&["unload", plist_path.to_str().unwrap()]) + .output(); + + // Load the service (this starts the daemon) + tracing::info!("Loading service with launchctl"); + let output = std::process::Command::new("launchctl") + .args(&["load", plist_path.to_str().unwrap()]) + .output() + .map_err(|e| format!("Failed to load service: {}", e))?; + + tracing::info!("launchctl load output: {:?}", String::from_utf8_lossy(&output.stdout)); + if !output.status.success() { + tracing::error!("launchctl load failed: {:?}", String::from_utf8_lossy(&output.stderr)); + } + + // Update daemon state - we no longer own the process + let mut state = daemon_state.write().await; + state.started_by_us = false; + state.daemon_process = None; + tracing::info!("Updated daemon state: started_by_us = false"); + drop(state); + + // Reset connection pool so it can reconnect to the service-managed daemon + tracing::info!("Resetting connection pool to reconnect to service daemon"); + app_state.connection_pool.reset().await; + + // Wait for daemon to start and become available + tracing::info!("Waiting for daemon to become available..."); + for i in 0..30 { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + if is_daemon_running(&socket_addr).await { + tracing::info!("Daemon is running after {} attempts", i + 1); + break; + } + if i == 29 { + return Err("Daemon failed to start after installing service".to_string()); + } + } + + // Trigger reconnection + tracing::info!("Triggering reconnection"); + app_state.connection_pool.ensure_connected(&app).await?; + + Ok(()) + } + + #[cfg(target_os = "linux")] + { + use std::io::Write; + + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let systemd_dir = std::path::PathBuf::from(&home).join(".config/systemd/user"); + + std::fs::create_dir_all(&systemd_dir) + .map_err(|e| format!("Failed to create systemd directory: {}", e))?; + + let service_path = systemd_dir.join("spacedrive-daemon.service"); + + let daemon_path = std::env::current_exe() + .map_err(|e| format!("Failed to get current exe: {}", e))? + .parent() + .ok_or_else(|| "Could not determine binary directory".to_string())? + .join("sd-daemon"); + + if !daemon_path.exists() { + return Err(format!("Daemon binary not found at {}", daemon_path.display())); + } + + let service_content = format!( + r#"[Unit] +Description=Spacedrive Daemon +After=network.target + +[Service] +Type=simple +ExecStart={} --data-dir {} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=default.target +"#, + daemon_path.display(), + data_dir.display() + ); + + let mut file = std::fs::File::create(&service_path) + .map_err(|e| format!("Failed to create service file: {}", e))?; + file.write_all(service_content.as_bytes()) + .map_err(|e| format!("Failed to write service file: {}", e))?; + + // Enable and start the service + std::process::Command::new("systemctl") + .args(&["--user", "daemon-reload"]) + .output() + .map_err(|e| format!("Failed to reload systemd: {}", e))?; + + std::process::Command::new("systemctl") + .args(&["--user", "enable", "spacedrive-daemon.service"]) + .output() + .map_err(|e| format!("Failed to enable service: {}", e))?; + + std::process::Command::new("systemctl") + .args(&["--user", "start", "spacedrive-daemon.service"]) + .output() + .map_err(|e| format!("Failed to start service: {}", e))?; + + // Update daemon state - we no longer own the process + let mut state = daemon_state.write().await; + state.started_by_us = false; + state.daemon_process = None; + tracing::info!("Updated daemon state: started_by_us = false"); + drop(state); + + // Reset connection pool so it can reconnect to the service-managed daemon + tracing::info!("Resetting connection pool to reconnect to service daemon"); + app_state.connection_pool.reset().await; + + // Wait for daemon to start and become available + tracing::info!("Waiting for daemon to become available..."); + for i in 0..30 { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + if is_daemon_running(&socket_addr).await { + tracing::info!("Daemon is running after {} attempts", i + 1); + break; + } + if i == 29 { + return Err("Daemon failed to start after installing service".to_string()); + } + } + + // Trigger reconnection + tracing::info!("Triggering reconnection"); + app_state.connection_pool.ensure_connected(&app).await?; + + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Service installation not supported on this platform".to_string()) + } +} + +/// Uninstall daemon service +#[tauri::command] +async fn uninstall_daemon_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let plist_path = std::path::PathBuf::from(&home).join("Library/LaunchAgents/com.spacedrive.daemon.plist"); + + if plist_path.exists() { + // Unload the service + let _ = std::process::Command::new("launchctl") + .args(&["unload", plist_path.to_str().unwrap()]) + .output(); + + std::fs::remove_file(&plist_path) + .map_err(|e| format!("Failed to remove plist file: {}", e))?; + } + + Ok(()) + } + + #[cfg(target_os = "linux")] + { + let home = std::env::var("HOME").map_err(|_| "Could not determine home directory".to_string())?; + let service_path = std::path::PathBuf::from(&home).join(".config/systemd/user/spacedrive-daemon.service"); + + if service_path.exists() { + // Stop and disable the service + let _ = std::process::Command::new("systemctl") + .args(&["--user", "stop", "spacedrive-daemon.service"]) + .output(); + + let _ = std::process::Command::new("systemctl") + .args(&["--user", "disable", "spacedrive-daemon.service"]) + .output(); + + std::fs::remove_file(&service_path) + .map_err(|e| format!("Failed to remove service file: {}", e))?; + + let _ = std::process::Command::new("systemctl") + .args(&["--user", "daemon-reload"]) + .output(); + } + + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Service installation not supported on this platform".to_string()) + } +} + /// Open macOS system settings for background items #[tauri::command] async fn open_macos_settings() -> Result<(), String> { @@ -1214,6 +1540,9 @@ fn main() { get_daemon_status, start_daemon_process, stop_daemon_process, + check_daemon_installed, + install_daemon_service, + uninstall_daemon_service, open_macos_settings, windows::show_window, windows::close_window, diff --git a/apps/tauri/src/platform.ts b/apps/tauri/src/platform.ts index dd4b3dabf..d68859384 100644 --- a/apps/tauri/src/platform.ts +++ b/apps/tauri/src/platform.ts @@ -177,6 +177,18 @@ export const platform: Platform = { return unlisten; }, + async checkDaemonInstalled() { + return await invoke("check_daemon_installed"); + }, + + async installDaemonService() { + await invoke("install_daemon_service"); + }, + + async uninstallDaemonService() { + await invoke("uninstall_daemon_service"); + }, + async openMacOSSettings() { await invoke("open_macos_settings"); }, diff --git a/core/src/infra/db/mod.rs b/core/src/infra/db/mod.rs index 666af6ba9..b953ab7b6 100644 --- a/core/src/infra/db/mod.rs +++ b/core/src/infra/db/mod.rs @@ -25,13 +25,16 @@ impl AsRef for Database { impl Database { /// Create a new database at the specified path pub async fn create(path: &Path) -> Result { - // Ensure parent directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| DbErr::Custom(format!("Failed to create directory: {}", e)))?; - } - - let db_url = format!("sqlite://{}?mode=rwc", path.display()); + let db_url = if path.as_os_str() == ":memory:" { + "sqlite::memory:".to_string() + } else { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| DbErr::Custom(format!("Failed to create directory: {}", e)))?; + } + format!("sqlite://{}?mode=rwc", path.display()) + }; // Connection pool sizing for concurrent indexing + sync operations // Supports: indexing (3-5) + sync (8-10) + content ID (3-5) + network (5-8) + headroom (5-10) diff --git a/packages/interface/src/components/DaemonDisconnectedOverlay.tsx b/packages/interface/src/components/DaemonDisconnectedOverlay.tsx index af3176032..63404fb11 100644 --- a/packages/interface/src/components/DaemonDisconnectedOverlay.tsx +++ b/packages/interface/src/components/DaemonDisconnectedOverlay.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Copy } from "@phosphor-icons/react"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; +import { usePlatform } from "../platform"; import { Button } from "@sd/ui"; import folderIcon from "@sd/assets/icons/FolderNoSpace.png"; @@ -36,9 +37,21 @@ export function DaemonDisconnectedOverlay({ }: { forceShow?: boolean; }) { - const { isConnected, isChecking, retryConnection } = useDaemonStatus(); - const [runInBackground, setRunInBackground] = useState(true); + const { isConnected, isChecking, isInstalled, startDaemon, installAndStartDaemon } = useDaemonStatus(); + const [installAsService, setInstallAsService] = useState(isInstalled); const prevConnected = useRef(isConnected); + const platform = usePlatform(); + + // Update checkbox when installation state changes + useEffect(() => { + console.log('[DaemonDisconnectedOverlay] isInstalled changed to:', isInstalled); + setInstallAsService(isInstalled); + }, [isInstalled]); + + // Log checkbox state changes + useEffect(() => { + console.log('[DaemonDisconnectedOverlay] installAsService checkbox state:', installAsService); + }, [installAsService]); // Reload when connection state changes from false to true useEffect(() => { @@ -68,15 +81,32 @@ export function DaemonDisconnectedOverlay({ transition={{ duration: 0.2 }} className="fixed inset-0 z-[9999] flex items-center justify-center backdrop-blur-lg bg-black/50" > -
-
- - {isConnected ? "Connected" : "Disconnected"} - +
+
+
+ + {isChecking ? "Starting..." : isConnected ? "Connected" : "Disconnected"} + +
+ +
+
+ + {isInstalled ? "Persistent" : "Temporary"} + +
@@ -102,17 +132,26 @@ export function DaemonDisconnectedOverlay({