Show remote activity on iPhone Home

This commit is contained in:
2026-08-11 17:12:33 +02:00
parent daf122aff3
commit 8e883f5ccf
9 changed files with 3377 additions and 35 deletions

View File

@@ -36,6 +36,7 @@ pub struct Config {
clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout,
mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>,
git_remotes: Vec<GitRemote>,
}
@@ -125,6 +126,10 @@ impl Config {
self.mobile_tab
}
pub fn mobile_home_refreshed_at(&self) -> Option<i64> {
self.mobile_home_refreshed_at
}
pub fn git_remotes(&self) -> &[GitRemote] {
&self.git_remotes
}
@@ -168,6 +173,36 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> {
if unix_seconds <= 0 {
return Err(ConfigError::InvalidField {
field: "ui.home_remote_refreshed_at_unix_seconds",
});
}
let mut document = self.document.clone();
let root = document
.as_table_mut()
.ok_or_else(|| ConfigError::Malformed {
path: self.source.clone(),
})?;
let ui = root
.entry("ui")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or(ConfigError::InvalidField { field: "ui" })?;
ui.insert(
"home_remote_refreshed_at_unix_seconds".to_owned(),
toml::Value::Integer(unix_seconds),
);
let raw = document
.clone()
.try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn create_mobile_clone(
source: PathBuf,
vault: &Path,
@@ -806,6 +841,7 @@ struct RawSecurity {
#[serde(deny_unknown_fields)]
struct RawUi {
selected_mobile_tab: Option<String>,
home_remote_refreshed_at_unix_seconds: Option<i64>,
}
#[derive(Deserialize)]
@@ -887,6 +923,15 @@ fn validate_config(
.map(MobileTab::from_config)
.transpose()?
.unwrap_or_default();
let mobile_home_refreshed_at = match raw.ui.home_remote_refreshed_at_unix_seconds {
Some(value) if value > 0 => Some(value),
Some(_) => {
return Err(ConfigError::InvalidField {
field: "ui.home_remote_refreshed_at_unix_seconds",
});
}
None => None,
};
let git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config {
@@ -899,6 +944,7 @@ fn validate_config(
clipboard_timeout,
authentication_timeout,
mobile_tab,
mobile_home_refreshed_at,
git_remotes,
})
}
@@ -1073,7 +1119,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
path: source.to_owned(),
})?;
validate_table(ui, "ui", &["selected_mobile_tab"])?;
validate_table(
ui,
"ui",
&[
"selected_mobile_tab",
"home_remote_refreshed_at_unix_seconds",
],
)?;
}
let Some(git) = root.get("git") else {
return Ok(());
@@ -1215,6 +1268,8 @@ fn native_config_directory() -> Option<PathBuf> {
mod tests {
use std::fs;
use crate::mobile::MobileTab;
use super::{Config, ConfigError, GitRemote};
#[test]
@@ -1234,8 +1289,16 @@ mod tests {
"repository-example",
)
.expect("remote");
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
.expect("create config");
assert_eq!(config.mobile_home_refreshed_at(), None);
config
.update_mobile_home_refresh(1_789_000_000)
.expect("persist refresh time");
Config::load(Some(&source))
.expect("reload timestamped config")
.update_mobile_tab(MobileTab::Preferences)
.expect("persist tab after refresh time");
let contents = fs::read_to_string(&source).expect("read config");
assert!(!contents.contains("token ="));
assert!(!contents.contains("password ="));
@@ -1251,12 +1314,15 @@ mod tests {
}
);
fs::rename(&original, &relocated).expect("relocate app container");
let relocated_config =
Config::load(Some(&relocated.join("config.toml"))).expect("reload config");
assert_eq!(
Config::load(Some(&relocated.join("config.toml")))
.expect("reload config")
.default_key()
.as_str(),
"ALICE"
(
relocated_config.default_key().as_str(),
relocated_config.mobile_home_refreshed_at(),
relocated_config.mobile_tab(),
),
("ALICE", Some(1_789_000_000), MobileTab::Preferences)
);
}
}

View File

@@ -180,6 +180,53 @@ pub struct GitSnapshot {
recent: Vec<GitLogEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitCommitActivity {
commit: GitLogEntry,
changes: Vec<GitChange>,
}
impl GitCommitActivity {
pub fn commit(&self) -> &GitLogEntry {
&self.commit
}
pub fn changes(&self) -> &[GitChange] {
&self.changes
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDivergence {
branch: String,
status: GitStatus,
remote: GitRemoteStatus,
incoming: Vec<GitCommitActivity>,
outgoing: Vec<GitCommitActivity>,
}
impl GitDivergence {
pub fn branch(&self) -> &str {
&self.branch
}
pub fn status(&self) -> &GitStatus {
&self.status
}
pub fn remote(&self) -> &GitRemoteStatus {
&self.remote
}
pub fn incoming(&self) -> &[GitCommitActivity] {
&self.incoming
}
pub fn outgoing(&self) -> &[GitCommitActivity] {
&self.outgoing
}
}
impl GitSnapshot {
pub fn root(&self) -> &Path {
&self.root
@@ -1698,32 +1745,144 @@ impl GitRepository {
})
}
/// Return commits and typed path changes on each side of the configured
/// remote-tracking branch without performing network access.
pub fn divergence(
&self,
configured: &GitRemote,
activity_limit: usize,
) -> Result<GitDivergence, GitError> {
let branch = self.current_branch()?;
let status = self.status()?;
let name = configured.name().as_str();
let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() {
return Err(GitError::ForbiddenRemoteUrl);
}
let local_id = self
.repository
.head_id()
.map_err(|_| GitError::UnbornHead)?
.detach();
let remote_ref = format!("refs/remotes/{name}/{branch}");
let remote_id = self
.repository
.find_reference(&remote_ref)
.map_err(|_| GitError::RemoteNotFound {
name: remote_ref.clone(),
})?
.into_fully_peeled_id()
.map_err(invalid)?
.detach();
let local_ids = self.ancestor_ids(local_id)?;
let remote_ids = self.ancestor_ids(remote_id)?;
let ahead = local_ids.difference(&remote_ids).count();
let behind = remote_ids.difference(&local_ids).count();
let incoming = self.commit_activities(remote_id, &local_ids, activity_limit)?;
let outgoing = self.commit_activities(local_id, &remote_ids, activity_limit)?;
Ok(GitDivergence {
branch,
status,
remote: GitRemoteStatus {
name: name.to_owned(),
url: actual_url,
ahead,
behind,
},
incoming,
outgoing,
})
}
fn commit_activities(
&self,
tip: gix::hash::ObjectId,
excluded: &BTreeSet<gix::hash::ObjectId>,
limit: usize,
) -> Result<Vec<GitCommitActivity>, GitError> {
if limit == 0 {
return Ok(Vec::new());
}
let commit = self
.repository
.find_object(tip)
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?;
let mut output = Vec::new();
for info in commit.ancestors().all().map_err(invalid)? {
let info = info.map_err(invalid)?;
if excluded.contains(&info.id) {
continue;
}
output.push(self.commit_activity(info.id)?);
if output.len() >= limit {
break;
}
}
Ok(output)
}
fn commit_activity(&self, id: gix::hash::ObjectId) -> Result<GitCommitActivity, GitError> {
let commit = self
.repository
.find_object(id)
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?;
let new_tree = commit.tree_id().map_err(invalid)?.detach();
let old_tree = commit
.parent_ids()
.next()
.map(|parent| {
self.repository
.find_object(parent.detach())
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?
.tree_id()
.map_err(invalid)
.map(|tree| tree.detach())
})
.transpose()?;
let old = tree_map_by_id(&self.repository, old_tree)?;
let new = tree_map_by_id(&self.repository, Some(new_tree))?;
Ok(GitCommitActivity {
commit: git_log_entry(&commit)?,
changes: compare_maps(&old, &new),
})
}
fn ahead_behind(
&self,
local: gix::hash::ObjectId,
remote: gix::hash::ObjectId,
) -> Result<(usize, usize), GitError> {
let ancestors = |id| -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
let commit = self
.repository
.find_object(id)
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?;
let mut ids = BTreeSet::from([id]);
for info in commit.ancestors().all().map_err(invalid)? {
ids.insert(info.map_err(invalid)?.id);
}
Ok(ids)
};
let local_ids = ancestors(local)?;
let remote_ids = ancestors(remote)?;
let local_ids = self.ancestor_ids(local)?;
let remote_ids = self.ancestor_ids(remote)?;
Ok((
local_ids.difference(&remote_ids).count(),
remote_ids.difference(&local_ids).count(),
))
}
fn ancestor_ids(
&self,
id: gix::hash::ObjectId,
) -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
let commit = self
.repository
.find_object(id)
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?;
let mut ids = BTreeSet::from([id]);
for info in commit.ancestors().all().map_err(invalid)? {
ids.insert(info.map_err(invalid)?.id);
}
Ok(ids)
}
fn update_remote_tracking(
&self,
remote: &str,
@@ -2329,16 +2488,7 @@ impl GitRepository {
}
let info = info.map_err(invalid)?;
let commit = info.object().map_err(invalid)?;
let decoded = commit.decode().map_err(invalid)?;
let author = decoded.author().map_err(invalid)?;
output.push(GitLogEntry {
id: commit.id.to_string(),
parents: decoded.parents().map(|id| id.to_string()).collect(),
author_name: author.name.to_str_lossy().into_owned(),
author_email: author.email.to_str_lossy().into_owned(),
message: decoded.message.to_str_lossy().into_owned(),
timestamp: author.time().map_err(invalid)?.seconds,
});
output.push(git_log_entry(&commit)?);
}
Ok(output)
}
@@ -2516,6 +2666,19 @@ fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
}
}
fn git_log_entry(commit: &gix::Commit<'_>) -> Result<GitLogEntry, GitError> {
let decoded = commit.decode().map_err(invalid)?;
let author = decoded.author().map_err(invalid)?;
Ok(GitLogEntry {
id: commit.id.to_string(),
parents: decoded.parents().map(|id| id.to_string()).collect(),
author_name: author.name.to_str_lossy().into_owned(),
author_email: author.email.to_str_lossy().into_owned(),
message: decoded.message.to_str_lossy().into_owned(),
timestamp: author.time().map_err(invalid)?.seconds,
})
}
impl EntryCommitter for GitRepository {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())

View File

@@ -15,6 +15,7 @@ pub mod generate;
pub mod git;
pub mod kdbx;
pub mod mobile;
pub mod mobile_home;
pub mod mobile_onboarding;
pub mod mutation;
pub mod otp;

View File

@@ -0,0 +1,979 @@
//! Storage-owned remote activity and pull-to-refresh models 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::{
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitIdentity,
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome,
},
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,
}
impl From<GitChangeKind> 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,
}
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
}
}
#[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<MobileHomeChange>,
}
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
}
}
#[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 {
freshness: MobileHomeFreshness,
refreshed_at: Option<i64>,
summaries: Vec<MobileHomeSummaryRow>,
incoming: Vec<MobileHomeCommit>,
outgoing: Vec<MobileHomeCommit>,
incoming_total: usize,
outgoing_total: usize,
notice: Option<MobileHomeNotice>,
}
impl MobileHomePage {
pub fn freshness(&self) -> MobileHomeFreshness {
self.freshness
}
pub fn refreshed_at(&self) -> Option<i64> {
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,
Finishing,
}
impl From<GitProgressPhase> for MobileHomePhase {
fn from(phase: GitProgressPhase) -> Self {
match phase {
GitProgressPhase::Validating => Self::Validating,
GitProgressPhase::Authenticating => Self::Authenticating,
GitProgressPhase::Receiving => Self::Receiving,
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
GitProgressPhase::Refreshing => Self::Finishing,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeProgress {
phase: MobileHomePhase,
title: String,
detail: String,
}
impl MobileHomeProgress {
pub fn phase(&self) -> MobileHomePhase {
self.phase
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
pub struct MobileHomeOperation {
control: GitOperationControl,
phase: Arc<Mutex<MobileHomePhase>>,
}
impl Default for MobileHomeOperation {
fn default() -> Self {
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
let observed = Arc::clone(&phase);
Self {
control: GitOperationControl::new(move |phase| {
if let Ok(mut current) = observed.lock() {
*current = phase.into();
}
}),
phase,
}
}
}
impl MobileHomeOperation {
pub fn cancel(&self) {
self.control.cancel();
}
pub fn progress(&self) -> MobileHomeProgress {
progress_copy(
self.phase
.lock()
.map_or(MobileHomePhase::Validating, |phase| *phase),
)
}
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
let storage = MobileHomeStorage::load()?;
let now = unix_seconds()?;
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
return storage.page(
MobileHomeFreshness::Cached,
storage.config.mobile_home_refreshed_at(),
None,
);
}
storage.refresh(now, &self.control)
}
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
}
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
}
}
struct MobileHomeStorage {
config: Config,
git: GitRepository,
remote: GitRemote,
}
impl MobileHomeStorage {
fn load() -> Result<Self, MobileHomeError> {
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, GitIdentity::ironstorage())
.map_err(MobileHomeError::from_git)?;
let remote = config
.git_remote(None)
.cloned()
.ok_or_else(MobileHomeError::missing_remote)?;
Ok(Self {
config,
git,
remote,
})
}
fn refresh(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let store = self.credentials()?;
let refreshed = self.git.fetch_with_transport_controlled(
&self.remote,
&store,
&crate::git::EmbeddedFetchTransport,
control,
);
let locked = store.lock();
refreshed.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, None)
}
fn pull(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let store = self.credentials()?;
let pulled = self.git.pull_with_transport_controlled(
&self.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 credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(MobileHomeError::from_secret)?;
store.unlock().map_err(MobileHomeError::from_secret)?;
Ok(store)
}
fn current_page(
&self,
now: i64,
notice: Option<MobileHomeNotice>,
) -> Result<MobileHomePage, MobileHomeError> {
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<i64>,
notice: Option<MobileHomeNotice>,
) -> Result<MobileHomePage, MobileHomeError> {
let divergence = self
.git
.divergence(&self.remote, ACTIVITY_LIMIT)
.map_err(MobileHomeError::from_git)?;
Ok(page_from_divergence(
&divergence,
freshness,
refreshed_at,
notice,
))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeErrorKind {
MissingConfiguration,
Configuration,
Authentication,
Conflict,
DirtyLocalChanges,
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::<Vec<_>>();
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::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 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(),
}
}
}
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<i64>,
notice: Option<MobileHomeNotice>,
) -> 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::<BTreeSet<_>>();
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(),
},
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()
},
},
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()
},
},
];
MobileHomePage {
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 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::<Vec<_>>();
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,
}
}
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::<String>();
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 progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
let (title, detail) = match phase {
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::Finishing => {
("Updating Home", "Building current activity and divergence.")
}
};
MobileHomeProgress {
phase,
title: title.to_owned(),
detail: detail.to_owned(),
}
}
fn unix_seconds() -> Result<i64, MobileHomeError> {
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<i64>, 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::{
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
mobile_change,
};
#[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<EFBFBD>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);
assert!(!offline.to_string().contains("token-value"));
}
}

View File

@@ -453,6 +453,23 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let snapshot = git.snapshot(Some(remote), 5)?;
assert_eq!(snapshot.remote().expect("remote status").ahead(), 1);
assert_eq!(snapshot.remote().expect("remote status").behind(), 1);
let divergence = git.divergence(remote, 10)?;
assert_eq!(divergence.remote().ahead(), 1);
assert_eq!(divergence.remote().behind(), 1);
assert_eq!(divergence.incoming().len(), 1);
assert_eq!(divergence.incoming()[0].commit().message(), "Remote change");
assert_eq!(divergence.incoming()[0].changes().len(), 1);
assert_eq!(
divergence.incoming()[0].changes()[0].path(),
Path::new("secret.gpg")
);
assert_eq!(divergence.outgoing().len(), 1);
assert_eq!(divergence.outgoing()[0].commit().message(), "Local change");
let totals_only = git.divergence(remote, 0)?;
assert_eq!(totals_only.remote().ahead(), 1);
assert_eq!(totals_only.remote().behind(), 1);
assert!(totals_only.incoming().is_empty());
assert!(totals_only.outgoing().is_empty());
assert_eq!(
git.resolve_fetched(