Implement iPhone and Apple Watch preferences (#53)

This commit is contained in:
hugo committed 2026-08-15 22:43:51 +02:00
1 parent 81886756a2
commit affdb55519
15 files changed
+1409 -60

No files matched your search

+311 -5
View File
@@ -1,12 +1,13 @@
//! Shared mobile authentication state; Swift only supplies input and presents results.
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex};
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
use crate::{
authentication::{
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession,
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
NativeAuthenticationSession,
},
config::{Config, ConfigError},
config::{Config, ConfigError, GitRemote, MobileAppearance},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
@@ -27,7 +28,9 @@ use crate::{
repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
},
secret_store::{SecretProtectionPolicy, SecretStoreError},
secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
},
write::{VaultWriter, WriteError},
};
@@ -129,6 +132,86 @@ pub struct MobileAuthenticationState {
remaining_seconds: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileWatchPreferenceState {
Unsupported,
NotPaired,
AppNotInstalled,
Ready,
Pending,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobilePreferences {
repository_title: String,
repository_url: String,
server_title: String,
server_identity: String,
application_account: Option<String>,
default_key_title: String,
default_key_fingerprint: String,
authentication_timeout_seconds: u64,
biometric_unlock_enabled: bool,
appearance: MobileAppearance,
watch_state: MobileWatchPreferenceState,
watch_title: String,
watch_detail: String,
}
impl MobilePreferences {
pub fn repository_title(&self) -> &str {
&self.repository_title
}
pub fn repository_url(&self) -> &str {
&self.repository_url
}
pub fn server_title(&self) -> &str {
&self.server_title
}
pub fn server_identity(&self) -> &str {
&self.server_identity
}
pub fn application_account(&self) -> Option<&str> {
self.application_account.as_deref()
}
pub fn default_key_title(&self) -> &str {
&self.default_key_title
}
pub fn default_key_fingerprint(&self) -> &str {
&self.default_key_fingerprint
}
pub fn authentication_timeout_seconds(&self) -> u64 {
self.authentication_timeout_seconds
}
pub fn biometric_unlock_enabled(&self) -> bool {
self.biometric_unlock_enabled
}
pub fn appearance(&self) -> MobileAppearance {
self.appearance
}
pub fn watch_state(&self) -> MobileWatchPreferenceState {
self.watch_state
}
pub fn watch_title(&self) -> &str {
&self.watch_title
}
pub fn watch_detail(&self) -> &str {
&self.watch_detail
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy {
value: String,
@@ -190,6 +273,8 @@ struct ActiveMobileLease {
struct MobileAuthenticationStatus {
biometric_unlock_enabled: bool,
authentication_timeout: AuthenticationTimeout,
appearance: MobileAppearance,
git_identity: GitIdentity,
active: Option<ActiveMobileLease>,
next_editor_id: u64,
@@ -256,6 +341,8 @@ impl MobileAuthentication {
Ok(Self {
status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(),
authentication_timeout: config.authentication_timeout(),
appearance: config.mobile_appearance(),
git_identity: config.git_identity().clone(),
active: None,
next_editor_id: 0,
@@ -430,6 +517,10 @@ impl MobileAuthentication {
Ok(self.status()?.git_identity.clone())
}
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationError> {
Ok(self.status()?.appearance)
}
pub fn set_git_identity(
&self,
name: String,
@@ -449,6 +540,112 @@ impl MobileAuthentication {
Ok(identity)
}
pub fn preferences(
&self,
watch_supported: bool,
watch_paired: bool,
watch_app_installed: bool,
) -> Result<MobilePreferences, MobileAuthenticationError> {
let remote = self
.config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.or_else(|| self.config.git_remotes().first())
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let handle = self
.keys
.resolve(self.config.default_key().as_str())
.map_err(key_error)?;
let key = self
.keys
.infos()
.find(|key| key.fingerprint() == handle.fingerprint())
.ok_or_else(|| config_detail("The default GPG key is unavailable."))?;
let status = self.status()?;
let selected = status.watch_shared_totp_entries.len();
let (watch_state, watch_title, watch_detail) =
watch_preference(watch_supported, watch_paired, watch_app_installed, selected);
let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs();
let biometric_unlock_enabled = status.biometric_unlock_enabled;
let appearance = status.appearance;
drop(status);
let server_title = remote.url().host_str().unwrap_or("HTTPS server").to_owned();
let repository_title = remote
.url()
.path_segments()
.and_then(Iterator::last)
.unwrap_or("Password Store")
.trim_end_matches(".git")
.to_owned();
Ok(MobilePreferences {
repository_title,
repository_url: remote.url().to_string(),
server_title,
server_identity: remote.server_id().as_str().to_owned(),
application_account: application_account(remote)?,
default_key_title: key
.user_ids()
.first()
.cloned()
.unwrap_or_else(|| self.config.default_key().to_string()),
default_key_fingerprint: key.fingerprint().to_string(),
authentication_timeout_seconds,
biometric_unlock_enabled,
appearance,
watch_state,
watch_title,
watch_detail,
})
}
pub fn set_authentication_timeout(
&self,
seconds: u64,
) -> Result<(), MobileAuthenticationError> {
let timeout = AuthenticationTimeout::new(Duration::from_secs(seconds))
.map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?;
let previous = self.status()?.authentication_timeout;
self.session
.set_timeout(timeout)
.map_err(MobileAuthenticationError::authentication)?;
if let Err(error) = self.config.update_authentication_timeout(timeout) {
let _ = self.session.set_timeout(previous);
return Err(config_error(error));
}
self.status()?.authentication_timeout = timeout;
Ok(())
}
pub fn set_mobile_appearance(
&self,
appearance: MobileAppearance,
) -> Result<(), MobileAuthenticationError> {
self.config
.update_mobile_appearance(appearance)
.map_err(config_error)?;
self.status()?.appearance = appearance;
Ok(())
}
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationError> {
let remote = self
.config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.or_else(|| self.config.git_remotes().first())
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let result = store.delete_https_git_credential(remote.server_id(), remote.application_id());
let lock_result = store.lock();
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
return Err(preference_secret_error(result.expect_err("checked error")));
}
lock_result.map_err(preference_secret_error)
}
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(|| {
@@ -1183,6 +1380,99 @@ fn config_error(error: ConfigError) -> MobileAuthenticationError {
)
}
fn config_detail(detail: &str) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Configuration,
"Preference Is Unavailable",
detail,
)
}
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(preference_secret_error)
}
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let result = store.https_git_credential_account(remote.server_id(), remote.application_id());
let lock_result = store.lock();
let account = match result {
Ok(account) => Some(account),
Err(SecretStoreError::Missing) => None,
Err(error) => return Err(preference_secret_error(error)),
};
lock_result
.map_err(preference_secret_error)
.map(|()| account)
}
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,
"Application Token Is Unavailable",
match error {
SecretStoreError::Denied => "Access to protected token storage was denied.".to_owned(),
SecretStoreError::Cancelled => {
"Access to protected token storage was cancelled.".to_owned()
}
_ => "Protected application-token storage is unavailable.".to_owned(),
},
)
}
fn watch_preference(
supported: bool,
paired: bool,
app_installed: bool,
selected: usize,
) -> (MobileWatchPreferenceState, String, String) {
if !supported {
return (
MobileWatchPreferenceState::Unsupported,
"Unavailable".to_owned(),
"Apple Watch connectivity is unavailable on this device.".to_owned(),
);
}
if !paired {
return (
MobileWatchPreferenceState::NotPaired,
"Not Paired".to_owned(),
"Pair an Apple Watch in the Watch app to enable synchronization.".to_owned(),
);
}
if !app_installed {
return (
MobileWatchPreferenceState::AppNotInstalled,
"Watch App Not Installed".to_owned(),
"Install the IronStorage companion on the paired Apple Watch.".to_owned(),
);
}
if selected == 0 {
return (
MobileWatchPreferenceState::Ready,
"Ready".to_owned(),
"No TOTP codes are selected for Apple Watch.".to_owned(),
);
}
(
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
format!(
"{selected} selected TOTP {} pending Apple Watch synchronization.",
if selected == 1 {
"code is"
} else {
"codes are"
}
),
)
}
fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
@@ -1293,7 +1583,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
#[cfg(test)]
mod tests {
use super::{
MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict,
MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
repository_operation_conflict, watch_preference,
};
#[test]
@@ -1319,4 +1610,19 @@ mod tests {
Some(MobileRepositoryOperationError::Busy)
);
}
#[test]
fn watch_preferences_render_platform_and_selection_state() {
assert_eq!(
watch_preference(true, false, false, 2).0,
MobileWatchPreferenceState::NotPaired
);
assert_eq!(
watch_preference(true, true, true, 0).0,
MobileWatchPreferenceState::Ready
);
let pending = watch_preference(true, true, true, 2);
assert_eq!(pending.0, MobileWatchPreferenceState::Pending);
assert!(pending.2.contains("2 selected TOTP codes"));
}
}