//! Long-lived owner of Appa state, synchronization, and local IPC. use std::{fs, path::Path}; use anyhow::Context; use tokio::{ net::{UnixListener, UnixStream}, time::{self, Duration}, }; use crate::{ app::{AppaService, run::SyncRunner}, ipc::{self, Command, IPC_PROTOCOL_VERSION, Request, Response}, storage::AppPaths, }; const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(250); const CONNECTION_MESSAGE_TIMEOUT: Duration = Duration::from_secs(8); /// Run the daemon, optionally limiting synchronization to one folder. /// /// The optional folder preserves the existing `appa run ` behavior; /// installed services use `None` and synchronize every registered folder. pub async fn serve(folder_path: Option<&Path>) -> anyhow::Result<()> { let paths = AppPaths::discover()?; let socket_path = paths.daemon_socket_path(); let service = AppaService::open_at(paths)?; let mut sync_runner = SyncRunner::start(&service, folder_path).await?; remove_stale_socket(&socket_path)?; let listener = UnixListener::bind(&socket_path).with_context(|| { format!( "could not bind Appa daemon socket at {}", socket_path.display() ) })?; let mut interval = time::interval(SOCKET_POLL_INTERVAL); tracing::info!("Appa daemon is ready"); loop { tokio::select! { _ = tokio::signal::ctrl_c() => break, result = async { interval.tick().await; sync_runner.tick(&service).await } => result?, accepted = listener.accept() => { let (mut stream, _) = accepted?; // Commands share the daemon's single SQLite connection and sync state. // Keep their ordering deterministic until folder-level operation locks exist. handle_connection(&service, &sync_runner, &mut stream).await; } } } sync_runner.shutdown().await?; remove_stale_socket(&socket_path) } async fn handle_connection( service: &AppaService, sync_runner: &SyncRunner, stream: &mut UnixStream, ) { let response = match read_request(stream).await { Ok(request) if request.protocol_version == IPC_PROTOCOL_VERSION => { handle_command(service, sync_runner, request.command).await } Ok(request) => Response::Error(format!( "unsupported Appa daemon protocol version {}; expected {IPC_PROTOCOL_VERSION}", request.protocol_version )), Err(error) => Response::Error(error.to_string()), }; if let Err(error) = write_response(stream, &response).await { tracing::debug!(%error, "Could not send Appa daemon response"); } } async fn read_request(stream: &mut UnixStream) -> anyhow::Result { tokio::time::timeout(CONNECTION_MESSAGE_TIMEOUT, ipc::read_message(stream)) .await .map_err(|_| anyhow::anyhow!("timed out waiting for an Appa daemon request"))? } async fn write_response(stream: &mut UnixStream, response: &Response) -> anyhow::Result<()> { tokio::time::timeout( CONNECTION_MESSAGE_TIMEOUT, ipc::write_message(stream, response), ) .await .map_err(|_| anyhow::anyhow!("timed out sending an Appa daemon response"))? } async fn handle_command( service: &AppaService, sync_runner: &SyncRunner, command: Command, ) -> Response { let result = match command { Command::Dashboard => service_dashboard(service).map(Response::Dashboard), Command::FolderDetails { folder_path } => { service_folder_details(service, &folder_path).map(Response::FolderDetails) } Command::RegisterFolder { folder_path } => service .register_folder(&folder_path) .map(|_| Response::Success), Command::ImportFolderInventory { inventory_path } => service .import_folder_inventory(&inventory_path) .map(|folders| Response::Count(folders.len())), Command::JoinFolder { folder_path, invitation, } => service .join_folder(&folder_path, &invitation) .map(|_| Response::Success), Command::CreateInvite { folder_path } => service .create_invite_with_node(&folder_path, sync_runner.node()) .await .map(Response::Invitation), Command::RevokeMember { folder_path, device_id, } => service .revoke_member(&folder_path, &device_id) .map(|_| Response::Success), Command::ForgetFolder { folder_path } => service .forget_folder(&folder_path) .map(|_| Response::Success), Command::RestoreRevision { folder_path, revision, } => service .restore(&folder_path, revision) .await .map(Response::Count), Command::Status { folder_path, json } => { service_status(service, folder_path.as_deref(), json).map(Response::Text) } Command::Peers { folder_path, json } => { service_peers(service, &folder_path, json).map(Response::Text) } Command::History { folder_path } => { service_history(service, &folder_path).map(Response::Text) } Command::Audit { folder_path, verify, } => service_audit(service, &folder_path, verify).map(Response::Text), Command::Conflicts { folder_path } => { service_conflicts(service, &folder_path).map(Response::Text) } Command::Members { folder_path } => { service_members(service, &folder_path).map(Response::Text) } Command::Doctor { json } => service_doctor(service, json).map(Response::Text), Command::DropLegacyTags { json } => service_drop_legacy_tags(sync_runner, json) .await .map(Response::Text), }; result.unwrap_or_else(|error| Response::Error(error.to_string())) } fn service_status( service: &AppaService, folder_path: Option<&Path>, json: bool, ) -> anyhow::Result { let statuses = match folder_path { Some(folder_path) => vec![service.status(folder_path)?], None => service.statuses()?, }; if json { let folders = statuses.iter().map(status_as_json).collect::>(); return Ok(serde_json::to_string(&folders)?); } Ok(statuses.iter().map(format_status).collect::()) } fn service_peers(service: &AppaService, folder_path: &Path, json: bool) -> anyhow::Result { let peers = service.peers(folder_path)?; if json { let peers = peers .into_iter() .map(|peer| { serde_json::json!({ "device_id": peer.device_id, "last_seen": peer.last_seen.to_string(), }) }) .collect::>(); return Ok(serde_json::to_string(&peers)?); } Ok(peers .into_iter() .map(|peer| format!("{}\n last seen: {}\n", peer.device_id, peer.last_seen)) .collect()) } fn service_history(service: &AppaService, folder_path: &Path) -> anyhow::Result { Ok(service .history(folder_path)? .into_iter() .map(|revision| { format!( "{}\t{}\t{} entries\n", revision.revision, revision.saved_at, revision.manifest.entries.len() ) }) .collect()) } fn service_audit( service: &AppaService, folder_path: &Path, verify: bool, ) -> anyhow::Result { if verify { let verification = service.verify_audit(folder_path)?; if verification.is_valid() { return Ok(format!( "Audit log is valid. Events: {}. Author chains: {}.\n", verification.event_count, verification.heads.len() )); } let faults = verification .faults .into_iter() .map(|fault| format!(" {}\n", fault.detail)) .collect::(); return Ok(format!("Audit log has integrity faults:\n{faults}")); } Ok(service .audit_log(folder_path)? .into_iter() .map(|event| { format!( "{}\t{}\t{}\t{}\n", event.sequence, event.author_device_id, format!("{:?}", event.kind), event.hash().unwrap_or_else(|_| "invalid".to_owned()) ) }) .collect()) } fn service_conflicts(service: &AppaService, folder_path: &Path) -> anyhow::Result { Ok(service .conflicts(folder_path)? .into_iter() .map(|conflict| { format!( "{}\n authored by {} at {}\n", conflict.path, conflict.author_device_id, conflict.modified_at ) }) .collect()) } fn service_members(service: &AppaService, folder_path: &Path) -> anyhow::Result { Ok(service .members(folder_path)? .into_iter() .map(|member| { format!( "{}\n {}\n", member.label.as_deref().unwrap_or("unnamed"), member.device_id ) }) .collect()) } fn service_doctor(service: &AppaService, json: bool) -> anyhow::Result { let report = service.doctor()?; if json { return Ok( serde_json::json!({"folder_count": report.folder_count, "issues": report.issues}) .to_string(), ); } if report.issues.is_empty() { return Ok(format!( "Appa storage and identity are healthy. Configured folders: {}\n", report.folder_count )); } Ok(format!( "Appa found {} issue(s):\n{}", report.issues.len(), report .issues .into_iter() .map(|issue| format!(" - {issue}\n")) .collect::() )) } async fn service_drop_legacy_tags(sync_runner: &SyncRunner, json: bool) -> anyhow::Result { let swept = sync_runner.node().sweep_legacy_tags().await?; Ok(format_legacy_tag_cleanup(swept, json)) } fn format_legacy_tag_cleanup(swept: u64, json: bool) -> String { if json { return serde_json::json!({ "dropped_legacy_tag_count": swept, "garbage_collection": "scheduled", }) .to_string(); } if swept == 0 { return "No persistent legacy blob tags found. Orphaned bytes will be reclaimed by periodic garbage collection.\n".to_owned(); } format!( "Dropped {swept} persistent legacy blob tag(s). Orphaned bytes will be reclaimed by periodic garbage collection.\n" ) } fn format_status(status: &crate::app::FolderStatus) -> String { let last_successful_sync = status .last_successful_sync .map(|time| time.to_string()) .unwrap_or_else(|| "never".to_owned()); let last_sync_error = status.last_sync_error.as_deref().unwrap_or("none"); let peers = status .peers .iter() .map(|peer| { format!( " {} — last seen {}, {}\n", peer.device_id, peer.last_seen, route_description(&peer.endpoint) ) }) .collect::(); format!( "{}\nActive members: {}\nKnown peers: {}\nFiles: {}\nDirectories: {}\nTombstones: {}\nConflicts: {}\nHistory revisions: {}\nLast successful sync: {}\nLast sync error: {}\n{}\n", status.folder.path.display(), status.member_count, status.peers.len(), status.file_count, status.directory_count, status.deleted_count, status.conflict_count, status.history_revision_count, last_successful_sync, last_sync_error, peers ) } fn status_as_json(status: &crate::app::FolderStatus) -> serde_json::Value { serde_json::json!({ "folder": status.folder.path, "folder_id": status.folder.id, "members": status.member_count, "peers": status.peers.iter().map(|peer| serde_json::json!({"device_id": peer.device_id, "last_seen": peer.last_seen.to_string(), "route": route_description(&peer.endpoint)})).collect::>(), "files": status.file_count, "directories": status.directory_count, "tombstones": status.deleted_count, "conflicts": status.conflict_count, "history_revisions": status.history_revision_count, "last_successful_sync": status.last_successful_sync.map(|time| time.to_string()), "last_sync_error": status.last_sync_error, }) } fn route_description(endpoint: &iroh::EndpointAddr) -> &'static str { if endpoint.ip_addrs().next().is_some() { "direct address known" } else if endpoint.relay_urls().next().is_some() { "relay address known" } else { "route lookup pending" } } fn service_dashboard(service: &AppaService) -> anyhow::Result { Ok(crate::core::Dashboard { folders: service .statuses()? .iter() .map(crate::core::FolderSummary::from) .collect(), }) } fn service_folder_details( service: &AppaService, folder_path: &Path, ) -> anyhow::Result { let status = service.status(folder_path)?; Ok(crate::core::FolderDetails { summary: (&status).into(), conflicts: service .conflicts(folder_path)? .into_iter() .map(Into::into) .collect(), members: service .members(folder_path)? .into_iter() .map(Into::into) .collect(), history_revision_count: status.history_revision_count, can_revoke_members: service.can_revoke_members(folder_path)?, }) } fn remove_stale_socket(socket_path: &Path) -> anyhow::Result<()> { match fs::remove_file(socket_path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => { Err(error).with_context(|| format!("could not remove {}", socket_path.display())) } } } #[cfg(test)] mod tests { use super::format_legacy_tag_cleanup; #[test] fn reports_cleanup_as_json_when_requested() { let report = format_legacy_tag_cleanup(3, true); let report: serde_json::Value = serde_json::from_str(&report).expect("valid JSON"); assert_eq!(report["dropped_legacy_tag_count"], 3); assert_eq!(report["garbage_collection"], "scheduled"); } #[test] fn reports_when_no_legacy_tags_were_found() { let report = format_legacy_tag_cleanup(0, false); assert!(report.contains("No persistent legacy blob tags found")); assert!(report.contains("periodic garbage collection")); } }