From 9c991f25cd56fd6001180c77c7c7bc6136767cad Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 3 Aug 2026 00:07:57 -0400 Subject: [PATCH] Clarify Appa terminology and local state --- README.md | 6 ++++- docs/architecture.md | 56 ++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 4 ++-- src/cli/status.rs | 53 +++++++++++++++++++++-------------------- src/cli/tooling.rs | 3 ++- src/service.rs | 12 ++++++++-- src/storage.rs | 11 ++++++--- src/storage/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index 10fa18b..4fb066a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ Appa is a peer-to-peer folder synchronization utility built in Rust on iroh. It watches local folders and syncs changes directly between your devices, with no account, central server, or web app. +See [the architecture reference](docs/architecture.md) for Appa's folder, +device, member, peer, node, and capability terminology. + ## Install Appa requires Rust 1.97.1 or newer. From the repository root: @@ -92,7 +95,8 @@ private/ .DS_Store ``` -Appa skips symbolic links. +Appa skips symbolic links. Non-Unicode filenames are rejected rather than +lossily renamed during synchronization. ## Handle conflicts diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5cb3556 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,56 @@ +# Appa architecture + +Appa is a single-binary, local-first folder synchronizer. Each running Appa +process is both a client and a server: it watches its registered folders, +serves manifests and blobs to authorized peers, and applies remote changes. + +## Terms + +- **Folder**: one local directory registered for synchronization. A folder has + a stable folder ID, a capability, a mode, a signed roster, and a manifest. +- **Device**: an Appa installation identified by its Iroh public key. The key + is stored in Appa state and is not derived from a hostname. +- **Member**: a device listed in a folder's signed roster. Members have owner, + read/write, or read-only rights. +- **Peer**: a reachable route for a member device. A peer may be reached over + a direct LAN/WAN address or through an Iroh relay. +- **Node**: the in-process Iroh endpoint that hosts folders and transfers + blobs. `appa run` creates one node for all configured folders. +- **Capability**: the secret required to request a folder's control data. An + invitation carries a capability and a signed roster; treat it like a + password. + +## Sync flow + +1. A local scan builds a manifest from the folder and imports changed files as + Iroh blobs. +2. The node advertises its manifest root to known peers. Announcements are an + optimization; periodic peer checks still converge after missed events. +3. Peers compare Merkle nodes and request only changed manifest entries. +4. Reconciliation applies newer versions, preserves concurrent edits as + conflict copies, and propagates tombstones for deletions. +5. Changed blobs are downloaded before filesystem materialization. Appa saves + a pending materialization record first, so an interrupted write can be + recovered on the next sync. + +All control streams and blob transfers are authenticated and encrypted by +Iroh. LAN discovery only provides direct route information; it does not change +the encryption or membership checks. + +## Local state and configuration + +Appa state contains the device identity, SQLite state database, blob cache, +and process locks. `APPA_HOME` overrides its location. `appa.toml` is optional +and declarative: it describes folders and reads capabilities from environment +variables, but it never stores a capability itself. + +The state directory is intentionally separate from synchronized folders. To +remove a folder's synchronization state without touching its files, use +`appa forget ` or `appa leave `. + +## Filesystem rules + +Appa ignores paths matched by `.appaignore` and skips symbolic links. Manifest +paths must be Unicode; a non-Unicode filename is rejected during scanning +instead of being lossy-converted into a different path. This is a deliberate +safety boundary while Appa's cross-platform manifest format uses text paths. diff --git a/src/cli.rs b/src/cli.rs index 22bc886..0daf9d1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -187,9 +187,9 @@ async fn run_app_command(command: Command) -> anyhow::Result<()> { } => { let folder = folder.map(PathBuf::from); if watch { - status::watch_status(&appa, folder).await?; + status::watch_status(&appa, folder.as_deref()).await?; } else { - status::print_status(&appa, folder, json)?; + status::print_status(&appa, folder.as_deref(), json)?; } } Command::Peers { folder, json } => print_peers(&appa, PathBuf::from(folder), json)?, diff --git a/src/cli/status.rs b/src/cli/status.rs index f7bbc47..e125ad1 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,16 +1,18 @@ -use std::path::PathBuf; +use std::{io::IsTerminal, path::Path}; use tokio::time::{Duration, sleep}; use crate::app::{AppaService, FolderStatus}; +const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(1); + pub(super) fn print_status( appa: &AppaService, - folder: Option, + folder: Option<&Path>, json: bool, ) -> anyhow::Result<()> { let statuses = match folder { - Some(folder) => vec![appa.status(&folder)?], + Some(folder) => vec![appa.status(folder)?], None => appa.statuses()?, }; if json { @@ -24,37 +26,38 @@ pub(super) fn print_status( Ok(()) } -pub(super) async fn watch_status( - appa: &AppaService, - folder: Option, -) -> anyhow::Result<()> { +pub(super) async fn watch_status(appa: &AppaService, folder: Option<&Path>) -> anyhow::Result<()> { loop { - print!("\x1b[2J\x1b[H"); - println!("Appa status — refreshing every second; press Ctrl-C to stop\n"); - print_status(appa, folder.clone(), false)?; + if std::io::stdout().is_terminal() { + print!("\x1b[2J\x1b[H"); + } + println!("Appa status - refreshing every second; press Ctrl-C to stop\n"); + if let Err(error) = print_status(appa, folder, false) { + tracing::warn!(%error, "Could not refresh Appa status"); + } tokio::select! { _ = tokio::signal::ctrl_c() => return Ok(()), - _ = sleep(Duration::from_secs(1)) => {} + _ = sleep(STATUS_REFRESH_INTERVAL) => {} } } } fn print_human_status(status: &FolderStatus) { + let folder_path = status.folder.path.display(); + 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"); println!( - "{}\nActive members: {}\nKnown peers: {}\nFiles: {}\nDirectories: {}\nTombstones: {}\nConflicts: {}\nHistory revisions: {}\nLast successful sync: {}\nLast sync error: {}", - 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, - status - .last_successful_sync - .map(|time| time.to_string()) - .unwrap_or_else(|| "never".to_owned()), - status.last_sync_error.as_deref().unwrap_or("none") + "{folder_path}\nActive members: {member_count}\nKnown peers: {peer_count}\nFiles: {file_count}\nDirectories: {directory_count}\nTombstones: {deleted_count}\nConflicts: {conflict_count}\nHistory revisions: {history_revision_count}\nLast successful sync: {last_successful_sync}\nLast sync error: {last_sync_error}", + member_count = status.member_count, + peer_count = status.peers.len(), + file_count = status.file_count, + directory_count = status.directory_count, + deleted_count = status.deleted_count, + conflict_count = status.conflict_count, + history_revision_count = status.history_revision_count, ); for peer in &status.peers { println!( diff --git a/src/cli/tooling.rs b/src/cli/tooling.rs index fd2bded..cf77de8 100644 --- a/src/cli/tooling.rs +++ b/src/cli/tooling.rs @@ -28,5 +28,6 @@ pub(super) fn run_service_command(command: ServiceCommand) -> anyhow::Result<()> pub(super) fn print_completions(shell: Shell) { let mut command = CommandLine::command(); - generate(shell, &mut command, "appa", &mut std::io::stdout()); + let command_name = command.get_name().to_owned(); + generate(shell, &mut command, command_name, &mut std::io::stdout()); } diff --git a/src/service.rs b/src/service.rs index 874cefb..cde3237 100644 --- a/src/service.rs +++ b/src/service.rs @@ -11,6 +11,8 @@ use directories::BaseDirs; use crate::storage::AppPaths; const UNIT_NAME: &str = "appa.service"; +const JOURNAL_LOG_LINE_COUNT: &str = "100"; +const RESTART_DELAY_SECONDS: u8 = 5; pub fn install() -> anyhow::Result { require_linux()?; @@ -51,7 +53,13 @@ pub fn restart() -> anyhow::Result<()> { pub fn logs() -> anyhow::Result<()> { require_linux()?; let status = Command::new("journalctl") - .args(["--user-unit", UNIT_NAME, "--no-pager", "--lines", "100"]) + .args([ + "--user-unit", + UNIT_NAME, + "--no-pager", + "--lines", + JOURNAL_LOG_LINE_COUNT, + ]) .status()?; if status.success() { return Ok(()); @@ -105,7 +113,7 @@ fn unit_contents(executable: &Path, data_directory: &Path) -> anyhow::Result, mode: FolderMode, ) -> anyhow::Result { - if let Some(folder) = self.find_folder(path)? { - return self.update_folder_mode(folder, mode); - } if !path.is_dir() { anyhow::bail!("{} is not a directory", path.display()); } + if let Some(folder) = self.find_folder(path)? { + return self.update_folder_mode(folder, mode); + } let folder = FolderConfig { id: id.unwrap_or_else(Uuid::new_v4), name: name.unwrap_or(folder_name(path)?), @@ -163,6 +163,11 @@ impl StateStore { anyhow::bail!("invitation roster belongs to a different folder"); } self.accept_roster(&invite.roster)?; + if let Some(existing_folder) = self.find_folder(path)? + && existing_folder.id != invite.folder_id + { + self.forget_folder(&existing_folder)?; + } let folder = FolderConfig { id: invite.folder_id, name: invite.folder_name.clone(), diff --git a/src/storage/tests.rs b/src/storage/tests.rs index afa3123..92386b3 100644 --- a/src/storage/tests.rs +++ b/src/storage/tests.rs @@ -99,6 +99,28 @@ fn increments_version_counters_in_sqlite() -> anyhow::Result<()> { Ok(()) } +#[test] +fn joining_a_new_folder_at_an_existing_path_removes_old_state() -> anyhow::Result<()> { + let directory = TempDir::new()?; + let folder_path = directory.path().join("folder"); + fs::create_dir(&folder_path)?; + let paths = AppPaths::from_data_directory(directory.path().join("state"))?; + let store = StateStore::open(&paths)?; + let old_folder = store.register_folder(&folder_path)?; + let invite = test_invite(uuid::Uuid::new_v4())?; + + let joined_folder = store.import_invite(&folder_path, &invite)?; + + assert_eq!(joined_folder.id, invite.folder_id); + let old_manifest_count: i64 = store.connection.query_row( + "SELECT COUNT(*) FROM manifests WHERE folder_id = ?1", + rusqlite::params![old_folder.id.to_string()], + |row| row.get(0), + )?; + assert_eq!(old_manifest_count, 0); + Ok(()) +} + #[test] fn reconstructs_manifest_history_from_checkpoints_and_deltas() -> anyhow::Result<()> { let directory = TempDir::new()?; @@ -205,6 +227,35 @@ fn test_file_entry(path: &str) -> crate::domain::Entry { } } +fn test_invite(folder_id: uuid::Uuid) -> anyhow::Result { + let owner = iroh::SecretKey::generate(); + let endpoint = iroh::EndpointAddr::new(owner.public()); + let capability = "folder-capability".to_owned(); + let mut roster = crate::domain::FolderRoster::create( + folder_id, + capability.clone(), + crate::domain::RosterMember { + device_id: endpoint.id.to_string(), + display_name: None, + role: crate::domain::MemberRole::Owner, + }, + )?; + roster.sign(&owner)?; + let mut invite = crate::protocol::Invite { + protocol_version: crate::protocol::PROTOCOL_VERSION, + folder_id, + folder_name: "folder".to_owned(), + inviter_device_id: endpoint.id.to_string(), + inviter_endpoint: endpoint, + capability, + roster, + expires_at: OffsetDateTime::now_utc() + time::Duration::hours(1), + signature: None, + }; + invite.sign(&owner)?; + Ok(invite) +} + #[test] fn rotating_a_capability_retains_its_previous_value() -> anyhow::Result<()> { let directory = TempDir::new()?; -- 2.51.2