//! Storage-owned remote activity and pull-then-push refresh for iPhone Home. use std::{ collections::BTreeSet, error::Error, fmt, path::Path, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use crate::{ config::{Config, ConfigError, GitRemote}, git::{ FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome, PushOutcome, }, mobile_authentication::{ MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError, }, repository::{Repository, RepositoryError}, secret_store::{ NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError, }, }; const ACTIVITY_LIMIT: usize = 50; const STALE_AFTER_SECONDS: i64 = 5 * 60; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomeFreshness { NeverRefreshed, Cached, Current, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomeChangeKind { PasswordEntry, RecipientPolicy, RecipientSignature, RepositoryFile, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomeChangeStatus { Added, Modified, Deleted, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomeActionKind { Commit, Fetch, Pull, Push, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeAction { kind: MobileHomeActionKind, title: &'static str, system_image: &'static str, } impl MobileHomeAction { pub fn kind(&self) -> MobileHomeActionKind { self.kind } pub fn title(&self) -> &str { self.title } pub fn system_image(&self) -> &str { self.system_image } } impl From for MobileHomeChangeStatus { fn from(kind: GitChangeKind) -> Self { match kind { GitChangeKind::Added => Self::Added, GitChangeKind::Modified => Self::Modified, GitChangeKind::Deleted => Self::Deleted, } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeSummaryRow { id: String, title: String, detail: String, system_image: String, actions: Vec, } impl MobileHomeSummaryRow { pub fn id(&self) -> &str { &self.id } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn system_image(&self) -> &str { &self.system_image } pub fn actions(&self) -> &[MobileHomeAction] { &self.actions } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeChange { id: String, title: String, detail: String, system_image: String, kind: MobileHomeChangeKind, status: MobileHomeChangeStatus, } impl MobileHomeChange { pub fn id(&self) -> &str { &self.id } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn system_image(&self) -> &str { &self.system_image } pub fn kind(&self) -> MobileHomeChangeKind { self.kind } pub fn status(&self) -> MobileHomeChangeStatus { self.status } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeCommit { id: String, title: String, detail: String, system_image: String, timestamp: i64, changes: Vec, actions: Vec, } impl MobileHomeCommit { pub fn id(&self) -> &str { &self.id } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn system_image(&self) -> &str { &self.system_image } pub fn timestamp(&self) -> i64 { self.timestamp } pub fn changes(&self) -> &[MobileHomeChange] { &self.changes } pub fn actions(&self) -> &[MobileHomeAction] { &self.actions } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeNotice { title: String, detail: String, system_image: String, } impl MobileHomeNotice { pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn system_image(&self) -> &str { &self.system_image } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomePage { remote_configured: bool, freshness: MobileHomeFreshness, refreshed_at: Option, summaries: Vec, incoming: Vec, outgoing: Vec, incoming_total: usize, outgoing_total: usize, notice: Option, } impl MobileHomePage { pub fn remote_configured(&self) -> bool { self.remote_configured } pub fn freshness(&self) -> MobileHomeFreshness { self.freshness } pub fn refreshed_at(&self) -> Option { self.refreshed_at } pub fn summaries(&self) -> &[MobileHomeSummaryRow] { &self.summaries } pub fn incoming(&self) -> &[MobileHomeCommit] { &self.incoming } pub fn outgoing(&self) -> &[MobileHomeCommit] { &self.outgoing } pub fn incoming_total(&self) -> usize { self.incoming_total } pub fn outgoing_total(&self) -> usize { self.outgoing_total } pub fn notice(&self) -> Option<&MobileHomeNotice> { self.notice.as_ref() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomePhase { Validating, Authenticating, Receiving, Integrating, Sending, Finishing, } impl From for MobileHomePhase { fn from(phase: GitProgressPhase) -> Self { match phase { GitProgressPhase::Validating => Self::Validating, GitProgressPhase::Authenticating => Self::Authenticating, GitProgressPhase::Receiving => Self::Receiving, GitProgressPhase::Integrating => Self::Integrating, GitProgressPhase::Sending => Self::Sending, GitProgressPhase::Refreshing => Self::Finishing, } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeProgress { action: Option, phase: MobileHomePhase, title: String, detail: String, } impl MobileHomeProgress { pub fn action(&self) -> Option { self.action } pub fn phase(&self) -> MobileHomePhase { self.phase } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } } pub struct MobileHomeOperation { authentication: Option>, control: GitOperationControl, phase: Arc>, action: Mutex>, } impl MobileHomeOperation { pub fn new(authentication: Option>) -> Self { let phase = Arc::new(Mutex::new(MobileHomePhase::Validating)); let observed = Arc::clone(&phase); Self { authentication, control: GitOperationControl::new(move |phase| { if let Ok(mut current) = observed.lock() { *current = phase.into(); } }), phase, action: Mutex::new(None), } } pub fn cancel(&self) { self.control.cancel(); } pub fn progress(&self) -> MobileHomeProgress { progress_copy( self.action.lock().ok().and_then(|action| *action), self.phase .lock() .map_or(MobileHomePhase::Validating, |phase| *phase), ) } pub fn cached(&self) -> Result { let storage = MobileHomeStorage::load()?; let refreshed_at = storage.config.mobile_home_refreshed_at(); storage.page( if refreshed_at.is_some() { MobileHomeFreshness::Cached } else { MobileHomeFreshness::NeverRefreshed }, refreshed_at, None, ) } pub fn refresh_if_stale(&self) -> Result { let storage = MobileHomeStorage::load()?; let now = unix_seconds()?; if storage.remote.is_none() { return storage.page(MobileHomeFreshness::Current, Some(now), None); } if !is_stale(storage.config.mobile_home_refreshed_at(), now) { return storage.page( MobileHomeFreshness::Cached, storage.config.mobile_home_refreshed_at(), None, ); } self.run(MobileHomeActionKind::Fetch, || { storage.fetch(now, &self.control) }) } pub fn commit(&self, message: &str) -> Result { if message.trim().is_empty() || message.contains('\0') { return Err(MobileHomeError::invalid_commit_message()); } self.run(MobileHomeActionKind::Commit, || { MobileHomeStorage::load()?.commit(message, &self.control) }) } pub fn fetch(&self) -> Result { self.run(MobileHomeActionKind::Fetch, || { MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control) }) } pub fn pull(&self) -> Result { self.run(MobileHomeActionKind::Pull, || { MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) }) } pub fn push(&self) -> Result { self.run(MobileHomeActionKind::Push, || { MobileHomeStorage::load()?.push(unix_seconds()?, &self.control) }) } pub fn sync(&self) -> Result { // Hold the pull reservation across both steps: sync can replace local entries. self.run(MobileHomeActionKind::Pull, || { MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control) }) } fn run( &self, action: MobileHomeActionKind, operation: impl FnOnce() -> Result, ) -> Result { if let Ok(mut current) = self.action.lock() { *current = Some(action); } let reservation = self .authentication .as_ref() .map(|authentication| { authentication .reserve_repository_operation(repository_operation(action)) .map_err(MobileHomeError::from_reservation) }) .transpose()?; let result = operation(); drop(reservation); result } } struct MobileHomeStorage { config: Config, git: GitRepository, remote: Option, } impl MobileHomeStorage { fn load() -> Result { let config = Config::load(None).map_err(MobileHomeError::from_config)?; let repository = Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?; let git = GitRepository::open(&repository, config.git_identity().clone()) .map_err(MobileHomeError::from_git)?; let remote = config.git_remote(None).cloned(); Ok(Self { config, git, remote, }) } fn commit( &self, message: &str, control: &GitOperationControl, ) -> Result { control .report(GitProgressPhase::Validating) .map_err(MobileHomeError::from_git)?; let commit = self .git .commit_all(message) .map_err(MobileHomeError::from_git)?; control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; self.page( MobileHomeFreshness::Cached, self.config.mobile_home_refreshed_at(), Some(commit_notice(&commit)), ) .map_err(MobileHomeError::after_commit) } fn fetch( &self, now: i64, control: &GitOperationControl, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; let fetched = self.git.fetch_with_transport_controlled( remote, &store, &crate::git::EmbeddedFetchTransport, control, ); let locked = store.lock(); let outcome = fetched.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { return Err(MobileHomeError::partial_secret(error)); } control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; self.current_page(now, Some(fetch_notice(&outcome))) } fn pull( &self, now: i64, control: &GitOperationControl, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; let pulled = self.git.pull_with_transport_controlled( remote, None, &store, &crate::git::EmbeddedFetchTransport, control, ); let locked = store.lock(); let outcome = pulled.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { return Err(MobileHomeError::partial_secret(error)); } control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; self.current_page(now, Some(pull_notice(outcome))) .map_err(MobileHomeError::after_pull) } fn push( &self, now: i64, control: &GitOperationControl, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; let pushed = self.git.push_with_transport_controlled( remote, None, &store, &crate::git::ReqwestGitTransport, control, ); let locked = store.lock(); let outcome = pushed.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { return Err(MobileHomeError::partial_secret(error)); } control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; self.current_page(now, Some(push_notice(&outcome))) .map_err(MobileHomeError::after_push) } fn sync( &self, now: i64, control: &GitOperationControl, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; let synced = self.git.sync_controlled(remote, &store, control); let locked = store.lock(); let (_, pushed) = synced.map_err(MobileHomeError::from_git)?; locked.map_err(MobileHomeError::partial_secret)?; control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; let mut notice = push_notice(&pushed); notice.title = "Sync Completed".to_owned(); notice.detail = format!("Pull completed successfully. {}", notice.detail); self.current_page(now, Some(notice)) .map_err(MobileHomeError::after_push) } fn credentials(&self) -> Result { let store = NativeSecretStore::system( SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ) .map_err(MobileHomeError::from_secret)?; store.unlock().map_err(MobileHomeError::from_secret)?; Ok(store) } fn remote(&self) -> Result<&GitRemote, MobileHomeError> { self.remote .as_ref() .ok_or_else(MobileHomeError::missing_remote) } fn current_page( &self, now: i64, notice: Option, ) -> Result { let persisted = self.config.update_mobile_home_refresh(now).is_ok(); let notice = if persisted { notice } else { Some(MobileHomeNotice { title: notice .as_ref() .map_or("Status Refreshed", MobileHomeNotice::title) .to_owned(), detail: "The repository was updated, but the refresh time could not be cached." .to_owned(), system_image: "exclamationmark.triangle".to_owned(), }) }; self.page(MobileHomeFreshness::Current, Some(now), notice) .map_err(MobileHomeError::after_refresh) } fn page( &self, freshness: MobileHomeFreshness, refreshed_at: Option, notice: Option, ) -> Result { if let Some(remote) = self.remote.as_ref() { let divergence = self .git .divergence(remote, ACTIVITY_LIMIT) .map_err(MobileHomeError::from_git)?; Ok(page_from_divergence( &divergence, freshness, refreshed_at, notice, )) } else { let snapshot = self .git .snapshot(None, ACTIVITY_LIMIT) .map_err(MobileHomeError::from_git)?; Ok(page_from_local_snapshot( &snapshot, freshness, refreshed_at, notice, )) } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileHomeErrorKind { MissingConfiguration, Configuration, Authentication, Conflict, DirtyLocalChanges, NoChanges, Offline, Interrupted, SecureStorage, Repository, PartialProgress, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeError { kind: MobileHomeErrorKind, title: &'static str, detail: String, } impl MobileHomeError { pub fn kind(&self) -> MobileHomeErrorKind { self.kind } pub fn title(&self) -> &str { self.title } pub fn detail(&self) -> &str { &self.detail } fn missing_remote() -> Self { Self { kind: MobileHomeErrorKind::Configuration, title: "Remote Is Not Configured", detail: "Set up an HTTPS password-store remote before refreshing Home.".to_owned(), } } fn from_config(error: ConfigError) -> Self { match error { ConfigError::NotFound { .. } => Self { kind: MobileHomeErrorKind::MissingConfiguration, title: "Set Up Password Store", detail: "Complete first-run setup before loading remote activity.".to_owned(), }, _ => Self { kind: MobileHomeErrorKind::Configuration, title: "Configuration Is Unavailable", detail: "Check the local IronStorage configuration and try again.".to_owned(), }, } } fn from_repository(_error: RepositoryError) -> Self { Self { kind: MobileHomeErrorKind::Repository, title: "Password Store Is Unavailable", detail: "The local password-store clone could not be opened.".to_owned(), } } fn from_git(error: GitError) -> Self { match error { GitError::AuthenticationFailed | GitError::CredentialsUnavailable | GitError::CredentialAccessDenied | GitError::CredentialCancelled => Self { kind: MobileHomeErrorKind::Authentication, title: "Authentication Failed", detail: "Update the application token in Preferences and try again.".to_owned(), }, GitError::MergeConflicts { conflicts } => { let names = conflicts .iter() .take(3) .map(|conflict| display_path(conflict.path())) .collect::>(); let suffix = if conflicts.len() > names.len() { format!(" and {} more", conflicts.len() - names.len()) } else { String::new() }; Self { kind: MobileHomeErrorKind::Conflict, title: "Pull Needs Conflict Resolution", detail: format!( "Remote changes were fetched, but the local clone was not changed. Resolve {}{} and pull again.", names.join(", "), suffix ), } } GitError::DirtyWorktree => Self { kind: MobileHomeErrorKind::DirtyLocalChanges, title: "Local Changes Need Attention", detail: "Commit or discard local changes before pulling remote activity.".to_owned(), }, GitError::NoChanges => Self { kind: MobileHomeErrorKind::NoChanges, title: "Nothing to Commit", detail: "The password-store working tree has no changes to commit.".to_owned(), }, GitError::NonFastForward => Self { kind: MobileHomeErrorKind::Conflict, title: "Pull Before Pushing", detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push." .to_owned(), }, GitError::NetworkUnavailable => Self { kind: MobileHomeErrorKind::Offline, title: "Server Is Offline", detail: "Cached activity remains available. Check the network and try again." .to_owned(), }, GitError::TlsFailed => Self { kind: MobileHomeErrorKind::Offline, title: "Secure Connection Failed", detail: "The HTTPS server identity could not be verified. Cached activity was not replaced." .to_owned(), }, GitError::Cancelled => Self { kind: MobileHomeErrorKind::Interrupted, title: "Refresh Interrupted", detail: "The local clone remains recoverable. Pull or refresh again when ready." .to_owned(), }, GitError::ForbiddenRemoteUrl => Self { kind: MobileHomeErrorKind::Configuration, title: "HTTPS Remote Required", detail: "The configured credential-free HTTPS remote does not match the local clone." .to_owned(), }, _ => Self { kind: MobileHomeErrorKind::Repository, title: "Remote Activity Is Unavailable", detail: "The storage-owned Git state could not be loaded.".to_owned(), }, } } fn invalid_commit_message() -> Self { Self { kind: MobileHomeErrorKind::NoChanges, title: "Commit Message Required", detail: "Enter a non-empty commit message and try again.".to_owned(), } } fn from_reservation(error: MobileRepositoryOperationError) -> Self { match error { MobileRepositoryOperationError::DirtyEditor => Self { kind: MobileHomeErrorKind::DirtyLocalChanges, title: "Unsaved Entry Edits", detail: "Save or discard the open entry editor before pulling or pushing." .to_owned(), }, MobileRepositoryOperationError::OpenEditor => Self { kind: MobileHomeErrorKind::DirtyLocalChanges, title: "Close the Entry Editor", detail: "Close the open entry editor before pulling so its source cannot change underneath it." .to_owned(), }, MobileRepositoryOperationError::Busy => Self { kind: MobileHomeErrorKind::Interrupted, title: "Password-Store Action in Progress", detail: "Wait for the current password-store action to finish, then retry." .to_owned(), }, MobileRepositoryOperationError::Unavailable => Self { kind: MobileHomeErrorKind::Repository, title: "Password-Store State Is Unavailable", detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry." .to_owned(), }, } } fn from_secret(error: SecretStoreError) -> Self { let detail = match error { SecretStoreError::Denied => "Access to the application token was denied.", SecretStoreError::Cancelled => "Application-token access was cancelled.", SecretStoreError::Missing => { "No application token is stored. Update it in Preferences." } _ => "Protected application-token storage is unavailable.", }; Self { kind: MobileHomeErrorKind::SecureStorage, title: "Token Is Unavailable", detail: detail.to_owned(), } } fn partial_secret(_error: SecretStoreError) -> Self { Self { kind: MobileHomeErrorKind::PartialProgress, title: "Remote Updated With a Warning", detail: "Remote data was received, but protected credential state could not be closed cleanly. Restart IronStorage before retrying." .to_owned(), } } fn after_refresh(_error: Self) -> Self { Self { kind: MobileHomeErrorKind::PartialProgress, title: "Remote Refreshed, Activity Unavailable", detail: "Remote tracking data was updated, but the refreshed activity page could not be built. The local clone remains unchanged." .to_owned(), } } fn after_pull(_error: Self) -> Self { Self { kind: MobileHomeErrorKind::PartialProgress, title: "Pull Completed, Activity Unavailable", detail: "The local clone was updated, but the refreshed activity page could not be built. Reload Home to retry." .to_owned(), } } fn after_commit(_error: Self) -> Self { Self { kind: MobileHomeErrorKind::PartialProgress, title: "Commit Completed, Status Unavailable", detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry." .to_owned(), } } fn after_push(_error: Self) -> Self { Self { kind: MobileHomeErrorKind::PartialProgress, title: "Push Completed, Status Unavailable", detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry." .to_owned(), } } } impl fmt::Display for MobileHomeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", self.title, self.detail) } } impl Error for MobileHomeError {} fn page_from_divergence( divergence: &GitDivergence, freshness: MobileHomeFreshness, refreshed_at: Option, notice: Option, ) -> MobileHomePage { let ahead = divergence.remote().ahead(); let behind = divergence.remote().behind(); let local_paths = divergence .status() .staged() .iter() .chain(divergence.status().unstaged()) .map(|change| change.path().to_owned()) .collect::>(); let divergence_title = match (behind, ahead) { (0, 0) => "Up to Date".to_owned(), (behind, 0) => format!("{behind} Incoming"), (0, ahead) => format!("{ahead} Outgoing"), (behind, ahead) => format!("{behind} Incoming · {ahead} Outgoing"), }; let divergence_detail = format!( "{} to pull · {} to push", commit_count(behind), commit_count(ahead) ); let local_count = local_paths.len(); let summaries = vec![ MobileHomeSummaryRow { id: "tracked-branch".to_owned(), title: format!("{}/{}", divergence.remote().name(), divergence.branch()), detail: "Tracked HTTPS branch".to_owned(), system_image: "point.3.connected.trianglepath.dotted".to_owned(), actions: vec![ mobile_action(MobileHomeActionKind::Fetch), mobile_action(MobileHomeActionKind::Pull), mobile_action(MobileHomeActionKind::Push), ], }, MobileHomeSummaryRow { id: "divergence".to_owned(), title: divergence_title, detail: divergence_detail, system_image: if ahead == 0 && behind == 0 { "checkmark.circle".to_owned() } else { "arrow.triangle.2.circlepath".to_owned() }, actions: [ (behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)), (ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)), ] .into_iter() .flatten() .collect(), }, MobileHomeSummaryRow { id: "working-tree".to_owned(), title: if local_count == 0 { "Working Tree Clean".to_owned() } else { format!("{} Local", change_count(local_count)) }, detail: if local_count == 0 { "No uncommitted password-store changes".to_owned() } else { "Commit or discard these changes before pulling".to_owned() }, system_image: if local_count == 0 { "checkmark.shield".to_owned() } else { "exclamationmark.triangle".to_owned() }, actions: if local_count > 0 { vec![mobile_action(MobileHomeActionKind::Commit)] } else { Vec::new() }, }, ]; MobileHomePage { remote_configured: true, freshness, refreshed_at, summaries, incoming: divergence .incoming() .iter() .map(|activity| mobile_commit(activity, true)) .collect(), outgoing: divergence .outgoing() .iter() .map(|activity| mobile_commit(activity, false)) .collect(), incoming_total: behind, outgoing_total: ahead, notice, } } fn page_from_local_snapshot( snapshot: &GitSnapshot, freshness: MobileHomeFreshness, refreshed_at: Option, notice: Option, ) -> MobileHomePage { let local_paths = snapshot .status() .staged() .iter() .chain(snapshot.status().unstaged()) .map(|change| change.path().to_owned()) .collect::>(); let commit_count = snapshot.recent().len(); let latest = snapshot .recent() .first() .and_then(|commit| { commit .message() .lines() .find(|line| !line.trim().is_empty()) }) .map(|line| display_text(line.trim(), 160)); MobileHomePage { remote_configured: false, freshness, refreshed_at, summaries: vec![ MobileHomeSummaryRow { id: "local-branch".to_owned(), title: snapshot.branch().to_owned(), detail: "Local Git branch · no sync remote configured".to_owned(), system_image: "externaldrive.fill".to_owned(), actions: Vec::new(), }, MobileHomeSummaryRow { id: "local-history".to_owned(), title: format!( "{} Local Commit{}", commit_count, if commit_count == 1 { "" } else { "s" } ), detail: latest.unwrap_or_else(|| "No local history yet".to_owned()), system_image: "clock.arrow.circlepath".to_owned(), actions: Vec::new(), }, working_tree_summary(local_paths.len()), ], incoming: Vec::new(), outgoing: Vec::new(), incoming_total: 0, outgoing_total: 0, notice, } } fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow { MobileHomeSummaryRow { id: "working-tree".to_owned(), title: if local_count == 0 { "Working Tree Clean".to_owned() } else { format!("{} Local", change_count(local_count)) }, detail: if local_count == 0 { "No uncommitted password-store changes".to_owned() } else { "Commit these local password-store changes".to_owned() }, system_image: if local_count == 0 { "checkmark.shield".to_owned() } else { "exclamationmark.triangle".to_owned() }, actions: if local_count > 0 { vec![mobile_action(MobileHomeActionKind::Commit)] } else { Vec::new() }, } } fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit { let commit = activity.commit(); let title = commit .message() .lines() .find(|line| !line.trim().is_empty()) .map(|line| display_text(line.trim(), 160)) .filter(|line| !line.is_empty()) .unwrap_or_else(|| "Untitled Commit".to_owned()); let author = display_text(commit.author_name(), 80); let changes = activity .changes() .iter() .enumerate() .map(|(index, change)| { mobile_change( format!("{}:{index}", commit.id()), change.path(), change.kind(), ) }) .collect::>(); MobileHomeCommit { id: commit.id().to_owned(), title, detail: format!( "{} · {}", if author.is_empty() { "Unknown author" } else { &author }, change_count(changes.len()) ), system_image: if incoming { "arrow.down.circle".to_owned() } else { "arrow.up.circle".to_owned() }, timestamp: commit.timestamp(), changes, actions: vec![mobile_action(if incoming { MobileHomeActionKind::Pull } else { MobileHomeActionKind::Push })], } } fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChange { let status = MobileHomeChangeStatus::from(kind); let status_name = match status { MobileHomeChangeStatus::Added => "Added", MobileHomeChangeStatus::Modified => "Modified", MobileHomeChangeStatus::Deleted => "Deleted", }; let file_name = path.file_name().and_then(|name| name.to_str()); let (title, detail, system_image, change_kind) = if path.extension().is_some_and(|extension| extension == "gpg") { ( display_path(&path.with_extension("")), format!("{status_name} password entry"), "key".to_owned(), MobileHomeChangeKind::PasswordEntry, ) } else if file_name == Some(".gpg-id") { ( policy_title(path, "recipients"), format!("{status_name} recipient policy"), "person.2".to_owned(), MobileHomeChangeKind::RecipientPolicy, ) } else if file_name == Some(".gpg-id.sig") { ( policy_title(path, "recipient signature"), format!("{status_name} recipient signature"), "signature".to_owned(), MobileHomeChangeKind::RecipientSignature, ) } else { ( display_path(path), format!("{status_name} repository file"), "doc".to_owned(), MobileHomeChangeKind::RepositoryFile, ) }; MobileHomeChange { id, title, detail, system_image, kind: change_kind, status, } } fn policy_title(path: &Path, suffix: &str) -> String { path.parent() .filter(|parent| !parent.as_os_str().is_empty()) .map_or_else( || format!("Root {suffix}"), |parent| format!("{} {suffix}", display_path(parent)), ) } fn display_path(path: &Path) -> String { display_text(&path.to_string_lossy(), 240) } fn display_text(value: &str, maximum: usize) -> String { let mut output = value .chars() .map(|character| { if character.is_control() { '\u{fffd}' } else { character } }) .take(maximum + 1) .collect::(); if output.chars().count() > maximum { output = output.chars().take(maximum.saturating_sub(1)).collect(); output.push('…'); } output } fn commit_count(count: usize) -> String { format!("{count} commit{}", if count == 1 { "" } else { "s" }) } fn change_count(count: usize) -> String { format!("{count} change{}", if count == 1 { "" } else { "s" }) } fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice { let (title, detail, system_image) = match outcome { PullOutcome::UpToDate => ( "Already Up to Date", "The local password store already contains every fetched commit.", "checkmark.circle", ), PullOutcome::FastForward => ( "Password Store Updated", "Remote commits were applied without changing local history.", "arrow.down.circle", ), PullOutcome::Merged => ( "Remote Changes Merged", "Remote and local commits were merged by storage.", "arrow.triangle.merge", ), }; MobileHomeNotice { title: title.to_owned(), detail: detail.to_owned(), system_image: system_image.to_owned(), } } fn commit_notice(commit: &str) -> MobileHomeNotice { MobileHomeNotice { title: "Changes Committed".to_owned(), detail: format!("Created commit {}.", &commit[..commit.len().min(12)]), system_image: "checkmark.circle".to_owned(), } } fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice { MobileHomeNotice { title: if outcome.received_pack() { "Remote Status Updated" } else { "Remote Already Current" } .to_owned(), detail: if outcome.received_pack() { format!( "Received new objects from {} without changing the local password store.", outcome.remote() ) } else { format!("{} has no new objects for this clone.", outcome.remote()) }, system_image: "arrow.clockwise.circle".to_owned(), } } fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice { let unchanged = outcome.old() == Some(outcome.new_id()); MobileHomeNotice { title: if unchanged { "Remote Already Current" } else { "Changes Pushed" } .to_owned(), detail: if unchanged { format!( "{}/{} already points to the local commit.", outcome.remote(), outcome.branch() ) } else { format!( "Updated {}/{} with local commits.", outcome.remote(), outcome.branch() ) }, system_image: "arrow.up.circle".to_owned(), } } fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction { let (title, system_image) = match kind { MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"), MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"), MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"), MobileHomeActionKind::Push => ("Push", "arrow.up.circle"), }; MobileHomeAction { kind, title, system_image, } } fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation { match action { MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit, MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch, MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull, MobileHomeActionKind::Push => MobileRepositoryOperation::Push, } } fn progress_copy( action: Option, phase: MobileHomePhase, ) -> MobileHomeProgress { let (title, detail) = match (action, phase) { (Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => ( "Committing Changes", "Staging current password-store changes and creating the commit in Rust.", ), (_, MobileHomePhase::Validating) => { ("Checking Home", "Validating local and remote Git state.") } (_, MobileHomePhase::Authenticating) => ( "Authenticating", "Reading the application token from protected storage.", ), (_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."), (_, MobileHomePhase::Integrating) => ( "Updating Password Store", "Integrating fetched commits into the local clone.", ), (_, MobileHomePhase::Sending) => { ("Pushing Changes", "Sending local Git objects over HTTPS.") } (_, MobileHomePhase::Finishing) => { ("Updating Home", "Building current activity and divergence.") } }; MobileHomeProgress { action, phase, title: title.to_owned(), detail: detail.to_owned(), } } fn unix_seconds() -> Result { let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| MobileHomeError { kind: MobileHomeErrorKind::Configuration, title: "System Time Is Invalid", detail: "Correct the device time before refreshing remote activity.".to_owned(), })? .as_secs(); i64::try_from(seconds).map_err(|_| MobileHomeError { kind: MobileHomeErrorKind::Configuration, title: "System Time Is Invalid", detail: "Correct the device time before refreshing remote activity.".to_owned(), }) } fn is_stale(refreshed_at: Option, now: i64) -> bool { !matches!( refreshed_at, Some(refreshed_at) if refreshed_at <= now && now.saturating_sub(refreshed_at) < STALE_AFTER_SECONDS ) } #[cfg(test)] mod tests { use std::path::Path; use crate::git::{GitChangeKind, GitError}; use super::{ MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy, }; #[test] fn freshness_and_display_safety_are_storage_owned() { assert!(is_stale(None, 1_000)); assert!(!is_stale(Some(900), 1_000)); assert!(is_stale(Some(699), 1_000)); assert!(is_stale(Some(1_001), 1_000)); assert_eq!(display_text("line\nsecret", 20), "line�secret"); assert_eq!(display_text("abcdef", 4), "abc…"); } #[test] fn changed_password_paths_are_classified_before_presentation() { let entry = mobile_change( "commit:0".to_owned(), Path::new("work/mail.gpg"), GitChangeKind::Modified, ); assert_eq!(entry.title(), "work/mail"); assert_eq!(entry.kind(), MobileHomeChangeKind::PasswordEntry); assert_eq!(entry.status(), MobileHomeChangeStatus::Modified); let policy = mobile_change( "commit:1".to_owned(), Path::new("team/.gpg-id"), GitChangeKind::Added, ); assert_eq!(policy.title(), "team recipients"); assert_eq!(policy.kind(), MobileHomeChangeKind::RecipientPolicy); } #[test] fn failures_are_typed_and_secret_safe() { let offline = super::MobileHomeError::from_git(GitError::NetworkUnavailable); assert_eq!(offline.kind(), MobileHomeErrorKind::Offline); let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree); assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges); let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward); assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict); let no_changes = super::MobileHomeError::from_git(GitError::NoChanges); assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges); assert!(!offline.to_string().contains("token-value")); } #[test] fn actions_and_progress_are_typed_before_swift_presentation() { let push = mobile_action(MobileHomeActionKind::Push); assert_eq!(push.title(), "Push"); assert_eq!(push.system_image(), "arrow.up.circle"); let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending); assert_eq!(progress.action(), Some(MobileHomeActionKind::Push)); assert_eq!(progress.title(), "Pushing Changes"); } }