//! Local IPC protocol and client for the Appa daemon. use std::{io, path::PathBuf, time::Duration}; use anyhow::Context; use serde::{Deserialize, Serialize}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::UnixStream, }; use crate::{ core::{Dashboard, FolderDetails}, storage::AppPaths, }; pub const IPC_PROTOCOL_VERSION: u16 = 1; const MAX_MESSAGE_BYTES: usize = 1024 * 1024; const DAEMON_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); #[derive(Debug, Deserialize, Serialize)] pub(crate) struct Request { pub protocol_version: u16, pub command: Command, } #[derive(Debug, Deserialize, Serialize)] pub(crate) enum Command { Dashboard, FolderDetails { folder_path: PathBuf, }, RegisterFolder { folder_path: PathBuf, }, ImportFolderInventory { inventory_path: PathBuf, }, JoinFolder { folder_path: PathBuf, invitation: String, }, CreateInvite { folder_path: PathBuf, }, RevokeMember { folder_path: PathBuf, device_id: String, }, ForgetFolder { folder_path: PathBuf, }, RestoreRevision { folder_path: PathBuf, revision: i64, }, Status { folder_path: Option, json: bool, }, Peers { folder_path: PathBuf, json: bool, }, History { folder_path: PathBuf, }, Audit { folder_path: PathBuf, verify: bool, }, Conflicts { folder_path: PathBuf, }, Members { folder_path: PathBuf, }, Doctor { json: bool, }, /// Drop persistent legacy blob tags; periodic GC reclaims orphaned bytes. DropLegacyTags { json: bool, }, } #[derive(Debug, Deserialize, Serialize)] pub(crate) enum Response { Dashboard(Dashboard), FolderDetails(FolderDetails), Invitation(String), Text(String), Count(usize), Success, Error(String), } /// A short-lived connection to the user-owned Appa daemon. #[derive(Clone, Debug)] pub struct DaemonClient { socket_path: PathBuf, } impl DaemonClient { pub fn connect() -> anyhow::Result { Ok(Self { socket_path: AppPaths::discover()?.daemon_socket_path(), }) } pub async fn dashboard(&self) -> anyhow::Result { match self.request(Command::Dashboard).await? { Response::Dashboard(dashboard) => Ok(dashboard), response => unexpected_response(response), } } pub async fn folder_details(&self, folder_path: PathBuf) -> anyhow::Result { match self.request(Command::FolderDetails { folder_path }).await? { Response::FolderDetails(details) => Ok(details), response => unexpected_response(response), } } pub async fn register_folder(&self, folder_path: PathBuf) -> anyhow::Result<()> { self.expect_success(Command::RegisterFolder { folder_path }) .await } pub async fn import_folder_inventory(&self, inventory_path: PathBuf) -> anyhow::Result { match self .request(Command::ImportFolderInventory { inventory_path }) .await? { Response::Count(count) => Ok(count), response => unexpected_response(response), } } pub async fn join_folder( &self, folder_path: PathBuf, invitation: String, ) -> anyhow::Result<()> { self.expect_success(Command::JoinFolder { folder_path, invitation, }) .await } pub async fn create_invite(&self, folder_path: PathBuf) -> anyhow::Result { match self.request(Command::CreateInvite { folder_path }).await? { Response::Invitation(invitation) => Ok(invitation), response => unexpected_response(response), } } pub async fn revoke_member( &self, folder_path: PathBuf, device_id: String, ) -> anyhow::Result<()> { self.expect_success(Command::RevokeMember { folder_path, device_id, }) .await } pub async fn forget_folder(&self, folder_path: PathBuf) -> anyhow::Result<()> { self.expect_success(Command::ForgetFolder { folder_path }) .await } pub async fn restore_revision( &self, folder_path: PathBuf, revision: i64, ) -> anyhow::Result { match self .request(Command::RestoreRevision { folder_path, revision, }) .await? { Response::Count(count) => Ok(count), response => unexpected_response(response), } } pub async fn status(&self, folder_path: Option, json: bool) -> anyhow::Result { self.request_text(Command::Status { folder_path, json }) .await } pub async fn peers(&self, folder_path: PathBuf, json: bool) -> anyhow::Result { self.request_text(Command::Peers { folder_path, json }) .await } pub async fn history(&self, folder_path: PathBuf) -> anyhow::Result { self.request_text(Command::History { folder_path }).await } pub async fn audit(&self, folder_path: PathBuf, verify: bool) -> anyhow::Result { self.request_text(Command::Audit { folder_path, verify, }) .await } pub async fn conflicts(&self, folder_path: PathBuf) -> anyhow::Result { self.request_text(Command::Conflicts { folder_path }).await } pub async fn members(&self, folder_path: PathBuf) -> anyhow::Result { self.request_text(Command::Members { folder_path }).await } pub async fn doctor(&self, json: bool) -> anyhow::Result { self.request_text(Command::Doctor { json }).await } pub async fn drop_legacy_tags(&self, json: bool) -> anyhow::Result { self.request_text(Command::DropLegacyTags { json }).await } async fn expect_success(&self, command: Command) -> anyhow::Result<()> { match self.request(command).await? { Response::Success => Ok(()), response => unexpected_response(response), } } async fn request_text(&self, command: Command) -> anyhow::Result { match self.request(command).await? { Response::Text(text) => Ok(text), response => unexpected_response(response), } } async fn request(&self, command: Command) -> anyhow::Result { tokio::time::timeout(DAEMON_REQUEST_TIMEOUT, self.request_inner(command)) .await .map_err(|_| { anyhow::anyhow!( "Appa daemon did not respond within {} seconds", DAEMON_REQUEST_TIMEOUT.as_secs() ) })? } async fn request_inner(&self, command: Command) -> anyhow::Result { let mut stream = UnixStream::connect(&self.socket_path) .await .with_context(|| { format!( "could not connect to Appa daemon at {}", self.socket_path.display() ) })?; write_message( &mut stream, &Request { protocol_version: IPC_PROTOCOL_VERSION, command, }, ) .await?; read_message(&mut stream).await } } fn unexpected_response(response: Response) -> anyhow::Result { match response { Response::Error(error) => Err(anyhow::anyhow!(error)), response => Err(anyhow::anyhow!( "Appa daemon returned an unexpected response: {response:?}" )), } } pub(crate) async fn read_message Deserialize<'de>>( stream: &mut UnixStream, ) -> anyhow::Result { let length = stream.read_u32().await? as usize; if length > MAX_MESSAGE_BYTES { anyhow::bail!("Appa daemon message exceeds {MAX_MESSAGE_BYTES} bytes"); } let mut bytes = vec![0; length]; stream.read_exact(&mut bytes).await?; Ok(serde_json::from_slice(&bytes)?) } pub(crate) async fn write_message( stream: &mut UnixStream, message: &T, ) -> anyhow::Result<()> { let bytes = serde_json::to_vec(message)?; let length = u32::try_from(bytes.len()) .map_err(|_| io::Error::other("Appa daemon message is too large"))?; stream.write_u32(length).await?; stream.write_all(&bytes).await?; stream.flush().await?; Ok(()) }