Dependency security audit / rustsec (push) Successful in 20s
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 1h15m17s
Tagged release / build-windows (push) Successful in 19m26s
Tagged release / publish-release (push) Successful in 3s
1455 lines
45 KiB
Rust
1455 lines
45 KiB
Rust
//! 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<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,
|
|
actions: Vec<MobileHomeAction>,
|
|
}
|
|
|
|
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<MobileHomeChange>,
|
|
actions: Vec<MobileHomeAction>,
|
|
}
|
|
|
|
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<i64>,
|
|
summaries: Vec<MobileHomeSummaryRow>,
|
|
incoming: Vec<MobileHomeCommit>,
|
|
outgoing: Vec<MobileHomeCommit>,
|
|
incoming_total: usize,
|
|
outgoing_total: usize,
|
|
notice: Option<MobileHomeNotice>,
|
|
}
|
|
|
|
impl MobileHomePage {
|
|
pub fn remote_configured(&self) -> bool {
|
|
self.remote_configured
|
|
}
|
|
|
|
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,
|
|
Sending,
|
|
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 => Self::Integrating,
|
|
GitProgressPhase::Sending => Self::Sending,
|
|
GitProgressPhase::Refreshing => Self::Finishing,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MobileHomeProgress {
|
|
action: Option<MobileHomeActionKind>,
|
|
phase: MobileHomePhase,
|
|
title: String,
|
|
detail: String,
|
|
}
|
|
|
|
impl MobileHomeProgress {
|
|
pub fn action(&self) -> Option<MobileHomeActionKind> {
|
|
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<Arc<MobileAuthentication>>,
|
|
control: GitOperationControl,
|
|
phase: Arc<Mutex<MobileHomePhase>>,
|
|
action: Mutex<Option<MobileHomeActionKind>>,
|
|
}
|
|
|
|
impl MobileHomeOperation {
|
|
pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> 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<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 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<MobileHomePage, MobileHomeError> {
|
|
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<MobileHomePage, MobileHomeError> {
|
|
self.run(MobileHomeActionKind::Fetch, || {
|
|
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
|
|
})
|
|
}
|
|
|
|
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
|
self.run(MobileHomeActionKind::Pull, || {
|
|
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
|
})
|
|
}
|
|
|
|
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
|
|
self.run(MobileHomeActionKind::Push, || {
|
|
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
|
|
})
|
|
}
|
|
|
|
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
|
|
// 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<MobileHomePage, MobileHomeError>,
|
|
) -> Result<MobileHomePage, MobileHomeError> {
|
|
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<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, 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<MobileHomePage, MobileHomeError> {
|
|
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<MobileHomePage, MobileHomeError> {
|
|
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<MobileHomePage, MobileHomeError> {
|
|
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<MobileHomePage, MobileHomeError> {
|
|
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<MobileHomePage, MobileHomeError> {
|
|
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<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 remote(&self) -> Result<&GitRemote, MobileHomeError> {
|
|
self.remote
|
|
.as_ref()
|
|
.ok_or_else(MobileHomeError::missing_remote)
|
|
}
|
|
|
|
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> {
|
|
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::<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::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<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(),
|
|
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<i64>,
|
|
notice: Option<MobileHomeNotice>,
|
|
) -> MobileHomePage {
|
|
let local_paths = snapshot
|
|
.status()
|
|
.staged()
|
|
.iter()
|
|
.chain(snapshot.status().unstaged())
|
|
.map(|change| change.path().to_owned())
|
|
.collect::<BTreeSet<_>>();
|
|
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::<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,
|
|
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::<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 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<MobileHomeActionKind>,
|
|
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<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::{
|
|
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");
|
|
}
|
|
}
|