Add daemon service management commands

This commit is contained in:
Jamie Pine
2025-12-04 18:10:24 -08:00
parent a3fdbf7c1e
commit 59d6f0d47e
8 changed files with 474 additions and 29 deletions

1
.gitignore vendored
View File

@@ -481,3 +481,4 @@ whitepaper/*.log
test_data
:memory:

View File

@@ -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<bool, 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");
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<RwLock<DaemonState>>>,
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#"<?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.spacedrive.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>--data-dir</string>
<string>{}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{}</string>
<key>StandardErrorPath</key>
<string>{}</string>
</dict>
</plist>"#,
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,

View File

@@ -177,6 +177,18 @@ export const platform: Platform = {
return unlisten;
},
async checkDaemonInstalled() {
return await invoke<boolean>("check_daemon_installed");
},
async installDaemonService() {
await invoke("install_daemon_service");
},
async uninstallDaemonService() {
await invoke("uninstall_daemon_service");
},
async openMacOSSettings() {
await invoke("open_macos_settings");
},

View File

@@ -25,13 +25,16 @@ impl AsRef<DatabaseConnection> for Database {
impl Database {
/// Create a new database at the specified path
pub async fn create(path: &Path) -> Result<Self, DbErr> {
// 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)

View File

@@ -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"
>
<div className="fixed right-4 top-4 flex items-center gap-2 rounded-full border border-app-line bg-app-box px-3 py-1.5 text-xs font-medium">
<div
className={`size-2 rounded-full ${
isConnected ? "bg-green-500" : "bg-red-500"
} animate-pulse`}
/>
<span className="text-ink-dull">
{isConnected ? "Connected" : "Disconnected"}
</span>
<div className="fixed right-4 top-4 flex items-center gap-2">
<div className="flex items-center gap-2 rounded-full border border-app-line bg-app-box px-3 py-1.5 text-xs font-medium">
<div
className={`size-2 rounded-full ${
isChecking
? "bg-yellow-500"
: isConnected
? "bg-green-500"
: "bg-red-500"
} animate-pulse`}
/>
<span className="text-ink-dull">
{isChecking ? "Starting..." : isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<div className="flex items-center gap-2 rounded-full border border-app-line bg-app-box px-3 py-1.5 text-xs font-medium">
<div
className={`size-2 rounded-full ${
isInstalled ? "bg-blue-500" : "bg-gray-500"
}`}
/>
<span className="text-ink-dull">
{isInstalled ? "Persistent" : "Temporary"}
</span>
</div>
</div>
<div className="flex max-w-4xl gap-8 rounded-lg border border-app-line p-8 shadow-2xl">
@@ -102,17 +132,26 @@ export function DaemonDisconnectedOverlay({
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={runInBackground}
onChange={(e) => setRunInBackground(e.target.checked)}
checked={installAsService}
onChange={async (e) => {
const shouldInstall = e.target.checked;
setInstallAsService(shouldInstall);
if (shouldInstall) {
await installAndStartDaemon();
} else {
await platform.uninstallDaemonService?.();
}
}}
className="size-4 cursor-pointer rounded border-app-line bg-app accent-accent"
/>
<span>Run daemon in background</span>
<span>Install as persistent service</span>
</label>
<div className="flex items-center gap-2">
<Button variant="gray">Help</Button>
<Button
onClick={retryConnection}
onClick={startDaemon}
disabled={isChecking}
variant="accent"
>

View File

@@ -6,7 +6,7 @@ import type { DirectorySortBy } from "@sd/ts-client";
export function GridView() {
const { currentPath, sortBy, viewSettings } = useExplorer();
const { isSelected, focusedIndex, selectedFiles, selectFile } = useSelection();
const { isSelected, focusedIndex, selectedFiles, selectFile, clearSelection } = useSelection();
const { gridSize, gapSize } = viewSettings;
const directoryQuery = useNormalizedCache({
@@ -26,13 +26,21 @@ export function GridView() {
const files = directoryQuery.data?.files || [];
const handleContainerClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
clearSelection();
}
};
return (
<div
className="grid p-3"
className="grid p-3 min-h-full"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))`,
gridAutoRows: 'max-content',
gap: `${gapSize}px`,
}}
onClick={handleContainerClick}
>
{files.map((file, index) => (
<FileCard

View File

@@ -4,6 +4,7 @@ import { usePlatform } from '../platform';
export interface DaemonStatus {
isConnected: boolean;
isChecking: boolean;
isInstalled: boolean;
}
export function useDaemonStatus() {
@@ -11,6 +12,7 @@ export function useDaemonStatus() {
const [status, setStatus] = useState<DaemonStatus>({
isConnected: true,
isChecking: false,
isInstalled: false,
});
useEffect(() => {
@@ -32,6 +34,7 @@ export function useDaemonStatus() {
if (mounted) {
const isRunning = daemonStatus?.is_running ?? false;
setStatus(prev => ({
...prev,
isConnected: isRunning,
// Only clear isChecking if we're connected (daemon started successfully)
isChecking: isRunning ? false : prev.isChecking,
@@ -56,11 +59,13 @@ export function useDaemonStatus() {
const setupListeners = async () => {
unlistenConnected = await platform.onDaemonConnected?.(() => {
console.log('[useDaemonStatus] daemon-connected event received');
if (mounted) {
setStatus({
setStatus(prev => ({
...prev,
isConnected: true,
isChecking: false,
});
}));
// Stop polling when connected
if (checkInterval) {
@@ -71,6 +76,7 @@ export function useDaemonStatus() {
});
unlistenDisconnected = await platform.onDaemonDisconnected?.(() => {
console.log('[useDaemonStatus] daemon-disconnected event received');
if (mounted) {
setStatus(prev => ({
...prev,
@@ -86,6 +92,7 @@ export function useDaemonStatus() {
});
unlistenStarting = await platform.onDaemonStarting?.(() => {
console.log('[useDaemonStatus] daemon-starting event received');
if (mounted) {
setStatus(prev => ({
...prev,
@@ -95,8 +102,25 @@ export function useDaemonStatus() {
});
};
// Initial check
// Check if daemon is installed as a service
const checkInstallation = async () => {
try {
const installed = await platform.checkDaemonInstalled?.();
console.log('[useDaemonStatus] checkInstallation result:', installed);
if (mounted) {
setStatus(prev => ({
...prev,
isInstalled: installed ?? false,
}));
}
} catch (error) {
console.error('[useDaemonStatus] Failed to check daemon installation:', error);
}
};
// Initial checks
checkDaemonStatus();
checkInstallation();
// Set up event listeners
setupListeners();
@@ -116,7 +140,7 @@ export function useDaemonStatus() {
};
}, [platform]);
const retryConnection = async () => {
const startDaemon = async () => {
try {
await platform.startDaemonProcess?.();
} catch (error) {
@@ -128,8 +152,28 @@ export function useDaemonStatus() {
}
};
const installAndStartDaemon = async () => {
console.log('[useDaemonStatus] installAndStartDaemon called');
try {
console.log('[useDaemonStatus] Calling platform.installDaemonService()');
await platform.installDaemonService?.();
console.log('[useDaemonStatus] installDaemonService completed, updating isInstalled state');
setStatus(prev => ({
...prev,
isInstalled: true,
}));
} catch (error) {
console.error('[useDaemonStatus] Failed to install daemon service:', error);
setStatus(prev => ({
...prev,
isChecking: false,
}));
}
};
return {
...status,
retryConnection,
startDaemon,
installAndStartDaemon,
};
}

View File

@@ -108,6 +108,15 @@ export type Platform = {
/** Listen for daemon starting events (Tauri only) */
onDaemonStarting?(callback: () => void): Promise<() => void>;
/** Check if daemon is installed as a service (Tauri only) */
checkDaemonInstalled?(): Promise<boolean>;
/** Install daemon as a service (Tauri only) */
installDaemonService?(): Promise<void>;
/** Uninstall daemon service (Tauri only) */
uninstallDaemonService?(): Promise<void>;
/** Open macOS system settings (Tauri/macOS only) */
openMacOSSettings?(): Promise<void>;