Implement iPhone and Apple Watch preferences (#53)

This commit is contained in:
2026-08-15 22:37:31 +02:00
parent 81886756a2
commit 5ae6ef55ad
14 changed files with 1402 additions and 60 deletions

View File

@@ -629,10 +629,16 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func manualLock() throws func manualLock() throws
func mobileAppearance() throws -> MobileAppearance
func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome
func preferences(watchSupported: Bool, watchPaired: Bool, watchAppInstalled: Bool) throws -> MobilePreferences
func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan
func removeApplicationToken() throws
func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage
func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage
@@ -645,10 +651,14 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func searchCachedTotpPage(query: String) throws -> MobileTotpPage? func searchCachedTotpPage(query: String) throws -> MobileTotpPage?
func setAuthenticationTimeout(seconds: UInt64) throws
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity
func setMobileAppearance(appearance: MobileAppearance) throws
func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail
func state() throws -> MobileAuthenticationState func state() throws -> MobileAuthenticationState
@@ -852,6 +862,15 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth
} }
} }
open func mobileAppearance()throws -> MobileAppearance {
return try FfiConverterTypeMobileAppearance_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance(
self.uniffiCloneHandle(),uniffiCallStatus
)
})
}
open func performEntryMutation(request: MobileMutationRequest)throws -> MobileMutationOutcome { open func performEntryMutation(request: MobileMutationRequest)throws -> MobileMutationOutcome {
return try FfiConverterTypeMobileMutationOutcome_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileMutationOutcome_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -862,6 +881,18 @@ open func performEntryMutation(request: MobileMutationRequest)throws -> MobileM
}) })
} }
open func preferences(watchSupported: Bool, watchPaired: Bool, watchAppInstalled: Bool)throws -> MobilePreferences {
return try FfiConverterTypeMobilePreferences_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(
self.uniffiCloneHandle(),
FfiConverterBool.lower(watchSupported),
FfiConverterBool.lower(watchPaired),
FfiConverterBool.lower(watchAppInstalled),uniffiCallStatus
)
})
}
open func prepareEntryMutation(path: String, action: MobileMutationAction)throws -> MobileMutationPlan { open func prepareEntryMutation(path: String, action: MobileMutationAction)throws -> MobileMutationPlan {
return try FfiConverterTypeMobileMutationPlan_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileMutationPlan_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -873,6 +904,14 @@ open func prepareEntryMutation(path: String, action: MobileMutationAction)throws
}) })
} }
open func removeApplicationToken()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token(
self.uniffiCloneHandle(),uniffiCallStatus
)
}
}
open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage { open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage {
return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -940,6 +979,15 @@ open func searchCachedTotpPage(query: String)throws -> MobileTotpPage? {
}) })
} }
open func setAuthenticationTimeout(seconds: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout(
self.uniffiCloneHandle(),
FfiConverterUInt64.lower(seconds),uniffiCallStatus
)
}
}
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState { open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -961,6 +1009,15 @@ open func setGitIdentity(name: String, email: String)throws -> MobileGitIdentit
}) })
} }
open func setMobileAppearance(appearance: MobileAppearance)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance(
self.uniffiCloneHandle(),
FfiConverterTypeMobileAppearance_lower(appearance),uniffiCallStatus
)
}
}
open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail { open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail {
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -3962,6 +4019,104 @@ public func FfiConverterTypeMobilePasswordRow_lower(_ value: MobilePasswordRow)
} }
public struct MobilePreferences: Equatable, Hashable {
public var repositoryTitle: String
public var repositoryUrl: String
public var serverTitle: String
public var serverIdentity: String
public var applicationAccount: String?
public var defaultKeyTitle: String
public var defaultKeyFingerprint: String
public var authenticationTimeoutSeconds: UInt64
public var biometricUnlockEnabled: Bool
public var appearance: MobileAppearance
public var watchState: MobileWatchPreferenceState
public var watchTitle: String
public var watchDetail: String
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(repositoryTitle: String, repositoryUrl: String, serverTitle: String, serverIdentity: String, applicationAccount: String?, defaultKeyTitle: String, defaultKeyFingerprint: String, authenticationTimeoutSeconds: UInt64, biometricUnlockEnabled: Bool, appearance: MobileAppearance, watchState: MobileWatchPreferenceState, watchTitle: String, watchDetail: String) {
self.repositoryTitle = repositoryTitle
self.repositoryUrl = repositoryUrl
self.serverTitle = serverTitle
self.serverIdentity = serverIdentity
self.applicationAccount = applicationAccount
self.defaultKeyTitle = defaultKeyTitle
self.defaultKeyFingerprint = defaultKeyFingerprint
self.authenticationTimeoutSeconds = authenticationTimeoutSeconds
self.biometricUnlockEnabled = biometricUnlockEnabled
self.appearance = appearance
self.watchState = watchState
self.watchTitle = watchTitle
self.watchDetail = watchDetail
}
}
#if compiler(>=6)
extension MobilePreferences: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobilePreferences: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePreferences {
return
try MobilePreferences(
repositoryTitle: FfiConverterString.read(from: &buf),
repositoryUrl: FfiConverterString.read(from: &buf),
serverTitle: FfiConverterString.read(from: &buf),
serverIdentity: FfiConverterString.read(from: &buf),
applicationAccount: FfiConverterOptionString.read(from: &buf),
defaultKeyTitle: FfiConverterString.read(from: &buf),
defaultKeyFingerprint: FfiConverterString.read(from: &buf),
authenticationTimeoutSeconds: FfiConverterUInt64.read(from: &buf),
biometricUnlockEnabled: FfiConverterBool.read(from: &buf),
appearance: FfiConverterTypeMobileAppearance.read(from: &buf),
watchState: FfiConverterTypeMobileWatchPreferenceState.read(from: &buf),
watchTitle: FfiConverterString.read(from: &buf),
watchDetail: FfiConverterString.read(from: &buf)
)
}
public static func write(_ value: MobilePreferences, into buf: inout [UInt8]) {
FfiConverterString.write(value.repositoryTitle, into: &buf)
FfiConverterString.write(value.repositoryUrl, into: &buf)
FfiConverterString.write(value.serverTitle, into: &buf)
FfiConverterString.write(value.serverIdentity, into: &buf)
FfiConverterOptionString.write(value.applicationAccount, into: &buf)
FfiConverterString.write(value.defaultKeyTitle, into: &buf)
FfiConverterString.write(value.defaultKeyFingerprint, into: &buf)
FfiConverterUInt64.write(value.authenticationTimeoutSeconds, into: &buf)
FfiConverterBool.write(value.biometricUnlockEnabled, into: &buf)
FfiConverterTypeMobileAppearance.write(value.appearance, into: &buf)
FfiConverterTypeMobileWatchPreferenceState.write(value.watchState, into: &buf)
FfiConverterString.write(value.watchTitle, into: &buf)
FfiConverterString.write(value.watchDetail, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobilePreferences_lift(_ buf: RustBuffer) throws -> MobilePreferences {
return try FfiConverterTypeMobilePreferences.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobilePreferences_lower(_ value: MobilePreferences) -> RustBuffer {
return FfiConverterTypeMobilePreferences.lower(value)
}
public struct MobileShell: Equatable, Hashable { public struct MobileShell: Equatable, Hashable {
public var selectedTab: MobileTab public var selectedTab: MobileTab
public var pages: [MobilePage] public var pages: [MobilePage]
@@ -4351,6 +4506,79 @@ public func FfiConverterTypeMobileWatchSnapshotStatus_lower(_ value: MobileWatch
public enum MobileAppearance: Equatable, Hashable {
case system
case light
case dark
}
#if compiler(>=6)
extension MobileAppearance: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileAppearance: FfiConverterRustBuffer {
typealias SwiftType = MobileAppearance
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAppearance {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .system
case 2: return .light
case 3: return .dark
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: MobileAppearance, into buf: inout [UInt8]) {
switch value {
case .system:
writeInt(&buf, Int32(1))
case .light:
writeInt(&buf, Int32(2))
case .dark:
writeInt(&buf, Int32(3))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileAppearance_lift(_ buf: RustBuffer) throws -> MobileAppearance {
return try FfiConverterTypeMobileAppearance.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileAppearance_lower(_ value: MobileAppearance) -> RustBuffer {
return FfiConverterTypeMobileAppearance.lower(value)
}
public enum MobileAuthenticationErrorKind: Equatable, Hashable { public enum MobileAuthenticationErrorKind: Equatable, Hashable {
case passphraseRequired case passphraseRequired
@@ -6383,6 +6611,93 @@ public func FfiConverterTypeMobileTotpDiscoveryPhase_lower(_ value: MobileTotpDi
public enum MobileWatchPreferenceState: Equatable, Hashable {
case unsupported
case notPaired
case appNotInstalled
case ready
case pending
}
#if compiler(>=6)
extension MobileWatchPreferenceState: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileWatchPreferenceState: FfiConverterRustBuffer {
typealias SwiftType = MobileWatchPreferenceState
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchPreferenceState {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .unsupported
case 2: return .notPaired
case 3: return .appNotInstalled
case 4: return .ready
case 5: return .pending
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: MobileWatchPreferenceState, into buf: inout [UInt8]) {
switch value {
case .unsupported:
writeInt(&buf, Int32(1))
case .notPaired:
writeInt(&buf, Int32(2))
case .appNotInstalled:
writeInt(&buf, Int32(3))
case .ready:
writeInt(&buf, Int32(4))
case .pending:
writeInt(&buf, Int32(5))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileWatchPreferenceState_lift(_ buf: RustBuffer) throws -> MobileWatchPreferenceState {
return try FfiConverterTypeMobileWatchPreferenceState.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileWatchPreferenceState_lower(_ value: MobileWatchPreferenceState) -> RustBuffer {
return FfiConverterTypeMobileWatchPreferenceState.lower(value)
}
public enum MobileWatchSnapshotState: Equatable, Hashable { public enum MobileWatchSnapshotState: Equatable, Hashable {
case unavailable case unavailable
@@ -7221,12 +7536,21 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance() != 3564) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation() != 7053) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation() != 7053) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences() != 16444) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token() != 11452) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -7245,12 +7569,18 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page() != 13608) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page() != 13608) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout() != 48083) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity() != 62371) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity() != 62371) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance() != 12133) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -318,16 +318,31 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity(
void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(uint64_t ptr, int8_t watch_supported, int8_t watch_paired, int8_t watch_app_installed, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
void uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status
@@ -358,6 +373,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_ed
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
@@ -368,6 +388,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance(uint64_t ptr, RustBuffer appearance, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
@@ -1026,18 +1051,36 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_ident
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
@@ -1074,6 +1117,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entr
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
@@ -1086,6 +1135,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biome
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED

View File

@@ -2,6 +2,7 @@ import UIKit
import AVFoundation import AVFoundation
import Vision import Vision
import VisionKit import VisionKit
import WatchConnectivity
extension Notification.Name { extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name( static let ironStorageLocalStoreDidChange = Notification.Name(
@@ -107,6 +108,13 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
} }
tabs.viewControllers = navigationControllers tabs.viewControllers = navigationControllers
tabs.delegate = self tabs.delegate = self
if let appearance = try? authentication?.mobileAppearance() {
tabs.overrideUserInterfaceStyle = switch appearance {
case .system: .unspecified
case .light: .light
case .dark: .dark
}
}
restoreSelectedTab() restoreSelectedTab()
monitorAuthentication() monitorAuthentication()
return tabs return tabs
@@ -844,13 +852,17 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
} }
@MainActor @MainActor
private final class PreferencesViewController: UITableViewController, MobileTabRoot { private final class PreferencesViewController: UITableViewController, MobileTabRoot,
WCSessionDelegate
{
fileprivate let shellTab = MobileTab.preferences fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage private var page: MobilePage
private let authentication: MobileAuthentication? private let authentication: MobileAuthentication?
private let keyTransfer = try? mobileKeyTransfer() private let keyTransfer = try? mobileKeyTransfer()
private var state: MobileAuthenticationState? private var state: MobileAuthenticationState?
private var preferences: MobilePreferences?
private var gitIdentity: MobileGitIdentity? private var gitIdentity: MobileGitIdentity?
private var watchSession: WCSession?
private var preferenceTask: Task<Void, Never>? private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var loadGeneration = 0 private var loadGeneration = 0
@@ -859,6 +871,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
self.page = page self.page = page
self.authentication = authentication self.authentication = authentication
super.init(style: .insetGrouped) super.init(style: .insetGrouped)
if WCSession.isSupported() {
let session = WCSession.default
watchSession = session
session.delegate = self
session.activate()
}
title = page.title title = page.title
navigationItem.largeTitleDisplayMode = .always navigationItem.largeTitleDisplayMode = .always
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -881,6 +899,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
} }
deinit { deinit {
watchSession?.delegate = nil
preferenceTask?.cancel() preferenceTask?.cancel()
loadTask?.cancel() loadTask?.cancel()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
@@ -892,14 +911,18 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
} }
override func numberOfSections(in tableView: UITableView) -> Int { override func numberOfSections(in tableView: UITableView) -> Int {
page.state == .ready ? 4 : 0 page.state == .ready ? 7 : 0
} }
override func tableView( override func tableView(
_ tableView: UITableView, _ tableView: UITableView,
numberOfRowsInSection section: Int numberOfRowsInSection section: Int
) -> Int { ) -> Int {
section == 1 ? 2 : (section == 3 ? 2 : 1) switch section {
case 0: 2
case 1, 2, 3: 3
default: 1
}
} }
override func tableView( override func tableView(
@@ -907,10 +930,13 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
titleForHeaderInSection section: Int titleForHeaderInSection section: Int
) -> String? { ) -> String? {
switch section { switch section {
case 0: "Git Commit Identity" case 0: "Password Store"
case 1: "GPG Key Transfer" case 1: "Application Token"
case 2: "Secure Unlock" case 2: "GPG Key & Recovery"
default: "Authentication Session" case 3: "Authentication"
case 4: "Appearance"
case 5: "Apple Watch"
default: "Git Commit Identity"
} }
} }
@@ -918,16 +944,22 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
_ tableView: UITableView, _ tableView: UITableView,
titleForFooterInSection section: Int titleForFooterInSection section: Int
) -> String? { ) -> String? {
if section == 0 { switch section {
return "Used as the author for commits created by the iPhone app." case 0:
"Non-secret repository and server identities from the shared configuration."
case 1:
"The application token is stored only in protected system storage and is never displayed."
case 2:
"Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation."
case 3:
"Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
case 5:
"Pairing is read from the device; TOTP selection and synchronization state come from Rust."
case 6:
"Used as the author for commits created by the iPhone app."
default:
nil
} }
if section == 1 {
return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation."
}
if section == 2 {
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
}
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
} }
override func tableView( override func tableView(
@@ -936,24 +968,58 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
) -> UITableViewCell { ) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration() var content = cell.defaultContentConfiguration()
if indexPath.section == 0 { switch (indexPath.section, indexPath.row) {
content.image = UIImage(systemName: "person.crop.circle") case (0, 0):
content.text = gitIdentity?.name ?? "Git Commit Identity" content.image = UIImage(systemName: "externaldrive.fill")
content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email" content.text = preferences?.repositoryTitle ?? "Password Store"
content.secondaryText = preferences?.repositoryUrl ?? "Repository unavailable"
cell.selectionStyle = .none
case (0, 1):
content.image = UIImage(systemName: "server.rack")
content.text = preferences?.serverTitle ?? "HTTPS Server"
content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable"
cell.selectionStyle = .none
case (1, 0):
content.image = UIImage(systemName: "key.fill")
content.text = preferences?.applicationAccount ?? "No Application Token"
content.secondaryText = preferences?.applicationAccount == nil
? "Git network operations require a replacement token"
: "Token stored in protected system storage"
cell.selectionStyle = .none
case (1, 1):
content.image = UIImage(systemName: "arrow.triangle.2.circlepath")
content.text = "Replace Token"
content.secondaryText = "Store a new HTTPS application token"
cell.accessoryType = .disclosureIndicator cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = authentication != nil case (1, 2):
cell.contentView.alpha = authentication == nil ? 0.45 : 1 content.image = UIImage(systemName: "trash")
} else if indexPath.section == 1 { content.text = "Remove Token"
let importing = indexPath.row == 0 content.textProperties.color = .systemRed
content.secondaryText = "Disable authenticated Git network operations"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = preferences?.applicationAccount != nil
cell.contentView.alpha = preferences?.applicationAccount == nil ? 0.45 : 1
case (2, 0):
content.image = UIImage(systemName: "person.badge.key.fill")
content.text = preferences?.defaultKeyTitle ?? "Default GPG Key"
content.secondaryText = preferences?.defaultKeyFingerprint ?? "Key unavailable"
cell.selectionStyle = .none
case (2, 1), (2, 2):
let importing = indexPath.row == 1
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode") content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
content.text = importing ? "Import GPG Key" : "Export GPG Key" content.text = importing ? "Import GPG Key" : "Export GPG Key"
content.secondaryText = importing content.secondaryText = importing
? "Scan one or more transfer QR codes" ? "Scan an initial or recovery key transfer"
: "Display public or private key armor" : "Display public or private key armor"
cell.accessoryType = .disclosureIndicator cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = keyTransfer != nil cell.isUserInteractionEnabled = keyTransfer != nil
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1 cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
} else if indexPath.section == 2 { case (3, 0):
content.image = UIImage(systemName: "timer")
content.text = "Inactivity Timeout"
content.secondaryText = "\(preferences?.authenticationTimeoutSeconds ?? 0) seconds"
cell.accessoryType = .disclosureIndicator
case (3, 1):
content.image = UIImage(systemName: "faceid") content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock" content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true content.secondaryText = state?.biometricUnlockEnabled == true
@@ -966,37 +1032,51 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
toggle.accessibilityLabel = "Biometric Unlock" toggle.accessibilityLabel = "Biometric Unlock"
cell.accessoryView = toggle cell.accessoryView = toggle
cell.selectionStyle = .none cell.selectionStyle = .none
} else if indexPath.row == 0 { case (3, 2):
let unlocked = state?.unlocked == true let unlocked = state?.unlocked == true
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill") content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
content.text = unlocked ? "Unlocked" : "Locked"
content.secondaryText = unlocked
? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
: "Protected content is masked"
cell.selectionStyle = .none
} else {
content.image = UIImage(systemName: "lock.fill")
content.text = "Lock Now" content.text = "Lock Now"
content.textProperties.color = .systemRed content.textProperties.color = .systemRed
content.secondaryText = "Revoke all active authentication handles" content.secondaryText = unlocked
? "Revoke all active authentication handles"
: "IronStorage is already locked"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = unlocked
cell.contentView.alpha = unlocked ? 1 : 0.45
case (4, 0):
content.image = UIImage(systemName: "circle.lefthalf.filled")
content.text = "App Appearance"
content.secondaryText = appearanceTitle(preferences?.appearance ?? .system)
cell.accessoryType = .disclosureIndicator
case (5, 0):
content.image = UIImage(systemName: watchImage(preferences?.watchState))
content.text = preferences?.watchTitle ?? "Checking Apple Watch"
content.secondaryText = preferences?.watchDetail ?? "Reading pairing state"
cell.accessoryType = .disclosureIndicator
default:
content.image = UIImage(systemName: "person.crop.circle")
content.text = gitIdentity?.name ?? "Git Commit Identity"
content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email"
cell.accessoryType = .disclosureIndicator cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = state?.unlocked == true
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
} }
content.secondaryTextProperties.numberOfLines = 0 content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content cell.contentConfiguration = content
cell.accessibilityLabel = [content.text, content.secondaryText]
.compactMap { $0 }
.joined(separator: ", ")
return cell return cell
} }
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 0 { switch (indexPath.section, indexPath.row) {
editGitIdentity() case (1, 1):
return tokenUpdateRequested()
} case (1, 2):
if indexPath.section == 1 { removeTokenRequested()
case (2, 1), (2, 2):
guard let keyTransfer else { return } guard let keyTransfer else { return }
if indexPath.row == 0 { if indexPath.row == 1 {
requestKeyScanner(keyTransfer) requestKeyScanner(keyTransfer)
} else { } else {
navigationController?.pushViewController( navigationController?.pushViewController(
@@ -1004,9 +1084,23 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
animated: true animated: true
) )
} }
return case (3, 0):
editAuthenticationTimeout()
case (3, 2):
lockNow()
case (4, 0):
chooseAppearance()
case (5, 0):
openWatchPreference()
case (6, 0):
editGitIdentity()
default:
break
} }
guard indexPath.section == 3, indexPath.row == 1, let authentication else { return } }
private func lockNow() {
guard let authentication else { return }
do { do {
try authentication.manualLock() try authentication.manualLock()
refreshState() refreshState()
@@ -1188,7 +1282,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
switch result { switch result {
case let .success(identity): case let .success(identity):
gitIdentity = identity gitIdentity = identity
tableView.reloadSections(IndexSet(integer: 0), with: .automatic) tableView.reloadSections(IndexSet(integer: 6), with: .automatic)
UIAccessibility.post( UIAccessibility.post(
notification: .announcement, notification: .announcement,
argument: "Git commit identity updated" argument: "Git commit identity updated"
@@ -1199,6 +1293,147 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
} }
} }
private func removeTokenRequested() {
guard preferences?.applicationAccount != nil else { return }
let alert = UIAlertController(
title: "Remove Application Token?",
message: "Local password browsing remains available, but Fetch, Pull, and Push will fail until a replacement token is stored.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) {
[weak self] _ in
self?.runPreferenceUpdate(announcement: "Application token removed") {
try self?.authentication?.removeApplicationToken()
}
})
present(alert, animated: true)
}
private func editAuthenticationTimeout() {
let alert = UIAlertController(
title: "Inactivity Timeout",
message: "Enter a shared timeout from 1 to 86400 seconds.",
preferredStyle: .alert
)
alert.addTextField { [preferences] field in
field.keyboardType = .numberPad
field.text = String(preferences?.authenticationTimeoutSeconds ?? 120)
field.accessibilityLabel = "Timeout in seconds"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Save", style: .default) {
[weak self, weak alert] _ in
let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0
self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") {
try self?.authentication?.setAuthenticationTimeout(seconds: seconds)
}
})
present(alert, animated: true)
}
private func chooseAppearance() {
let alert = UIAlertController(title: "App Appearance", message: nil, preferredStyle: .actionSheet)
for appearance in [MobileAppearance.system, .light, .dark] {
let selected = appearance == preferences?.appearance
alert.addAction(UIAlertAction(
title: "\(selected ? "" : "")\(appearanceTitle(appearance))",
style: .default
) { [weak self] _ in
self?.runPreferenceUpdate(announcement: "Appearance updated") {
try self?.authentication?.setMobileAppearance(appearance: appearance)
}
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.popoverPresentationController?.sourceView = tableView
alert.popoverPresentationController?.sourceRect = tableView.bounds
present(alert, animated: true)
}
private func runPreferenceUpdate(
announcement: String,
operation: @escaping @Sendable () throws -> Void
) {
preferenceTask?.cancel()
preferenceTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try operation()
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case .success:
refreshState()
UIAccessibility.post(notification: .announcement, argument: announcement)
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
private func openWatchPreference() {
switch preferences?.watchState {
case .ready, .pending:
selectTotpTab()
case .notPaired:
presentWatchGuidance(
title: "Pair an Apple Watch",
message: "Open the Watch app on this iPhone and pair an Apple Watch before configuring IronStorage synchronization."
)
case .appNotInstalled:
presentWatchGuidance(
title: "Install IronStorage on Apple Watch",
message: "Open the Watch app on this iPhone and install the IronStorage companion on the paired watch."
)
default:
presentWatchGuidance(
title: "Apple Watch Unavailable",
message: "Apple Watch connectivity is unavailable on this device."
)
}
}
private func presentWatchGuidance(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
private func selectTotpTab() {
guard let controllers = tabBarController?.viewControllers else { return }
tabBarController?.selectedIndex = controllers.firstIndex { controller in
guard
let navigation = controller as? UINavigationController,
let root = navigation.viewControllers.first as? MobileTabRoot
else { return false }
return root.shellTab == .totp
} ?? tabBarController?.selectedIndex ?? 0
}
private func appearanceTitle(_ appearance: MobileAppearance) -> String {
switch appearance {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
private func watchImage(_ state: MobileWatchPreferenceState?) -> String {
switch state {
case .ready: "applewatch.radiowaves.left.and.right"
case .pending: "arrow.triangle.2.circlepath"
case .notPaired, .appNotInstalled: "applewatch.slash"
default: "applewatch"
}
}
@objc private func authenticationDidChange() { @objc private func authenticationDidChange() {
refreshState() refreshState()
} }
@@ -1244,8 +1479,38 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
private func refreshState() { private func refreshState() {
state = try? authentication?.state() state = try? authentication?.state()
gitIdentity = try? authentication?.gitIdentity() gitIdentity = try? authentication?.gitIdentity()
preferences = try? authentication?.preferences(
watchSupported: WCSession.isSupported(),
watchPaired: watchSession?.isPaired ?? false,
watchAppInstalled: watchSession?.isWatchAppInstalled ?? false
)
if let appearance = preferences?.appearance {
tabBarController?.overrideUserInterfaceStyle = switch appearance {
case .system: .unspecified
case .light: .light
case .dark: .dark
}
}
tableView.reloadData() tableView.reloadData()
} }
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in self?.refreshState() }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.refreshState() }
}
} }
@MainActor @MainActor

View File

@@ -20,6 +20,8 @@
<string>1.0</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key> <key>WKCompanionAppBundleIdentifier</key>
<string>de.rfc1437.ironstorage</string> <string>de.rfc1437.ironstorage</string>
</dict> </dict>

View File

@@ -31,6 +31,9 @@ targets:
- Assets.xcassets - Assets.xcassets
- Sources/App - Sources/App
- Generated/ironstorage_apple.swift - Generated/ironstorage_apple.swift
dependencies:
- target: IronStorageWatch
embed: true
preBuildScripts: preBuildScripts:
- name: Build Rust core - name: Build Rust core
basedOnDependencyAnalysis: false basedOnDependencyAnalysis: false
@@ -78,6 +81,7 @@ targets:
path: Watch-Info.plist path: Watch-Info.plist
properties: properties:
CFBundleDisplayName: IronStorage CFBundleDisplayName: IronStorage
WKApplication: true
WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage
sources: sources:
- Sources/Watch - Sources/Watch

View File

@@ -10,13 +10,15 @@ use std::{
}; };
use ironstorage::{ use ironstorage::{
config::ConfigError, config::{ConfigError, MobileAppearance as StorageMobileAppearance},
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
mobile_authentication::{ mobile_authentication::{
MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationError as StorageAuthenticationError,
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
MobileAuthenticationState as StorageAuthenticationState, MobileAuthenticationState as StorageAuthenticationState,
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation, MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
MobilePreferences as StorageMobilePreferences,
MobileWatchPreferenceState as StorageWatchPreferenceState,
}, },
mobile_entry::{ mobile_entry::{
MobileEntryEditorFieldKind as StorageEntryEditorFieldKind, MobileEntryEditorFieldKind as StorageEntryEditorFieldKind,
@@ -623,6 +625,91 @@ pub struct MobileAuthenticationState {
pub remaining_seconds: u64, pub remaining_seconds: u64,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileAppearance {
System,
Light,
Dark,
}
impl From<StorageMobileAppearance> for MobileAppearance {
fn from(appearance: StorageMobileAppearance) -> Self {
match appearance {
StorageMobileAppearance::System => Self::System,
StorageMobileAppearance::Light => Self::Light,
StorageMobileAppearance::Dark => Self::Dark,
}
}
}
impl From<MobileAppearance> for StorageMobileAppearance {
fn from(appearance: MobileAppearance) -> Self {
match appearance {
MobileAppearance::System => Self::System,
MobileAppearance::Light => Self::Light,
MobileAppearance::Dark => Self::Dark,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileWatchPreferenceState {
Unsupported,
NotPaired,
AppNotInstalled,
Ready,
Pending,
}
impl From<StorageWatchPreferenceState> for MobileWatchPreferenceState {
fn from(state: StorageWatchPreferenceState) -> Self {
match state {
StorageWatchPreferenceState::Unsupported => Self::Unsupported,
StorageWatchPreferenceState::NotPaired => Self::NotPaired,
StorageWatchPreferenceState::AppNotInstalled => Self::AppNotInstalled,
StorageWatchPreferenceState::Ready => Self::Ready,
StorageWatchPreferenceState::Pending => Self::Pending,
}
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobilePreferences {
pub repository_title: String,
pub repository_url: String,
pub server_title: String,
pub server_identity: String,
pub application_account: Option<String>,
pub default_key_title: String,
pub default_key_fingerprint: String,
pub authentication_timeout_seconds: u64,
pub biometric_unlock_enabled: bool,
pub appearance: MobileAppearance,
pub watch_state: MobileWatchPreferenceState,
pub watch_title: String,
pub watch_detail: String,
}
impl From<StorageMobilePreferences> for MobilePreferences {
fn from(preferences: StorageMobilePreferences) -> Self {
Self {
repository_title: preferences.repository_title().to_owned(),
repository_url: preferences.repository_url().to_owned(),
server_title: preferences.server_title().to_owned(),
server_identity: preferences.server_identity().to_owned(),
application_account: preferences.application_account().map(str::to_owned),
default_key_title: preferences.default_key_title().to_owned(),
default_key_fingerprint: preferences.default_key_fingerprint().to_owned(),
authentication_timeout_seconds: preferences.authentication_timeout_seconds(),
biometric_unlock_enabled: preferences.biometric_unlock_enabled(),
appearance: preferences.appearance().into(),
watch_state: preferences.watch_state().into(),
watch_title: preferences.watch_title().to_owned(),
watch_detail: preferences.watch_detail().to_owned(),
}
}
}
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileGitIdentity { pub struct MobileGitIdentity {
pub name: String, pub name: String,
@@ -1400,6 +1487,49 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationFfiError> {
self.authentication
.mobile_appearance()
.map(Into::into)
.map_err(Into::into)
}
pub fn preferences(
&self,
watch_supported: bool,
watch_paired: bool,
watch_app_installed: bool,
) -> Result<MobilePreferences, MobileAuthenticationFfiError> {
self.authentication
.preferences(watch_supported, watch_paired, watch_app_installed)
.map(Into::into)
.map_err(Into::into)
}
pub fn set_authentication_timeout(
&self,
seconds: u64,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_authentication_timeout(seconds)
.map_err(Into::into)
}
pub fn set_mobile_appearance(
&self,
appearance: MobileAppearance,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_mobile_appearance(appearance.into())
.map_err(Into::into)
}
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.remove_application_token()
.map_err(Into::into)
}
pub fn set_git_identity( pub fn set_git_identity(
&self, &self,
name: String, name: String,

View File

@@ -134,7 +134,7 @@ struct LeaseState {
struct Shared<B: SecretStoreBackend, C: AuthenticationClock> { struct Shared<B: SecretStoreBackend, C: AuthenticationClock> {
store: SecretStore<B>, store: SecretStore<B>,
timeout: AuthenticationTimeout, timeout: Mutex<AuthenticationTimeout>,
clock: C, clock: C,
/// Serializes access checks with relock, eliminating check-then-use races. /// Serializes access checks with relock, eliminating check-then-use races.
operation: Mutex<()>, operation: Mutex<()>,
@@ -168,9 +168,14 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationSess
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let timeout = self
.shared
.timeout
.lock()
.map_or_else(|poisoned| *poisoned.into_inner(), |timeout| *timeout);
formatter formatter
.debug_struct("AuthenticationSession") .debug_struct("AuthenticationSession")
.field("timeout", &self.shared.timeout) .field("timeout", &timeout)
.field("unlock_material", &"[REDACTED]") .field("unlock_material", &"[REDACTED]")
.finish() .finish()
} }
@@ -203,7 +208,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
// The lease is the only cache. This prevents a second lifetime // The lease is the only cache. This prevents a second lifetime
// policy in SecretStore from retaining duplicate records. // policy in SecretStore from retaining duplicate records.
store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections), store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections),
timeout, timeout: Mutex::new(timeout),
clock, clock,
operation: Mutex::new(()), operation: Mutex::new(()),
state: Mutex::new(LeaseState { state: Mutex::new(LeaseState {
@@ -308,6 +313,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
let _operation = self.shared.operation()?; let _operation = self.shared.operation()?;
self.shared.revoke(RevocationReason::Cancelled) self.shared.revoke(RevocationReason::Cancelled)
} }
/// Apply a new shared inactivity timeout to this process and any active lease.
pub fn set_timeout(&self, timeout: AuthenticationTimeout) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
let deadline = self
.shared
.clock
.now()
.checked_add(timeout.duration())
.ok_or(AuthenticationError::ClockOverflow)?;
*self.shared.timeout()? = timeout;
if let Some(active) = self.shared.state()?.active.as_mut() {
active.deadline = deadline;
}
Ok(())
}
} }
/// Generation-bound secret access. Clones are revoked together. /// Generation-bound secret access. Clones are revoked together.
@@ -351,7 +373,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
self.shared.expire_if_needed()?; self.shared.expire_if_needed()?;
let now = self.shared.clock.now(); let now = self.shared.clock.now();
let deadline = now let deadline = now
.checked_add(self.shared.timeout.duration()) .checked_add(self.shared.timeout()?.duration())
.ok_or(AuthenticationError::ClockOverflow)?; .ok_or(AuthenticationError::ClockOverflow)?;
self.shared self.shared
.with_active(self.generation, |active| active.deadline = deadline) .with_active(self.generation, |active| active.deadline = deadline)
@@ -432,10 +454,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
} }
fn timeout(
&self,
) -> Result<std::sync::MutexGuard<'_, AuthenticationTimeout>, AuthenticationError> {
self.timeout
.lock()
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
}
fn begin_lease(&self) -> Result<u64, AuthenticationError> { fn begin_lease(&self) -> Result<u64, AuthenticationError> {
let now = self.clock.now(); let now = self.clock.now();
let deadline = now let deadline = now
.checked_add(self.timeout.duration()) .checked_add(self.timeout()?.duration())
.ok_or(AuthenticationError::ClockOverflow)?; .ok_or(AuthenticationError::ClockOverflow)?;
let (generation, was_active) = { let (generation, was_active) = {
let mut state = self.state()?; let mut state = self.state()?;

View File

@@ -40,6 +40,7 @@ pub struct Config {
clipboard_timeout: ClipboardTimeout, clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout, authentication_timeout: AuthenticationTimeout,
biometric_unlock_enabled: bool, biometric_unlock_enabled: bool,
mobile_appearance: MobileAppearance,
mobile_tab: MobileTab, mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>, mobile_home_refreshed_at: Option<i64>,
watch_shared_totp_entries: BTreeSet<EntryPath>, watch_shared_totp_entries: BTreeSet<EntryPath>,
@@ -60,6 +61,35 @@ pub struct ConfigSettings {
authentication_timeout: Duration, authentication_timeout: Duration,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MobileAppearance {
#[default]
System,
Light,
Dark,
}
impl MobileAppearance {
fn from_config(value: &str) -> Result<Self, ConfigError> {
match value {
"system" => Ok(Self::System),
"light" => Ok(Self::Light),
"dark" => Ok(Self::Dark),
_ => Err(ConfigError::InvalidField {
field: "ui.mobile_appearance",
}),
}
}
const fn config_value(self) -> &'static str {
match self {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
}
}
}
impl ConfigSettings { impl ConfigSettings {
pub fn vault(&self) -> &Path { pub fn vault(&self) -> &Path {
&self.vault &self.vault
@@ -133,6 +163,10 @@ impl Config {
self.biometric_unlock_enabled self.biometric_unlock_enabled
} }
pub fn mobile_appearance(&self) -> MobileAppearance {
self.mobile_appearance
}
pub fn mobile_tab(&self) -> MobileTab { pub fn mobile_tab(&self) -> MobileTab {
self.mobile_tab self.mobile_tab
} }
@@ -280,6 +314,66 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist() validate_config(self.source.clone(), document, raw)?.persist()
} }
pub fn update_authentication_timeout(
&self,
timeout: AuthenticationTimeout,
) -> Result<(), ConfigError> {
let mut document = self.current_document()?;
let root = document
.as_table_mut()
.ok_or_else(|| ConfigError::Malformed {
path: self.source.clone(),
})?;
let security = root
.entry("security")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or(ConfigError::InvalidField { field: "security" })?;
let seconds =
i64::try_from(timeout.duration().as_secs()).map_err(|_| ConfigError::InvalidField {
field: "security.inactivity_timeout_seconds",
})?;
security.insert(
"inactivity_timeout_seconds".to_owned(),
toml::Value::Integer(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 fn update_mobile_appearance(
&self,
appearance: MobileAppearance,
) -> Result<(), ConfigError> {
let mut document = self.current_document()?;
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(
"mobile_appearance".to_owned(),
toml::Value::String(appearance.config_value().to_owned()),
);
let raw = document
.clone()
.try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> { pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> {
let mut document = self.current_document()?; let mut document = self.current_document()?;
let root = document let root = document
@@ -952,6 +1046,7 @@ struct RawSecurity {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawUi { struct RawUi {
selected_mobile_tab: Option<String>, selected_mobile_tab: Option<String>,
mobile_appearance: Option<String>,
home_remote_refreshed_at_unix_seconds: Option<i64>, home_remote_refreshed_at_unix_seconds: Option<i64>,
#[serde(default)] #[serde(default)]
watch_shared_totp_entries: Vec<String>, watch_shared_totp_entries: Vec<String>,
@@ -1032,6 +1127,13 @@ fn validate_config(
field: "security.inactivity_timeout_seconds", field: "security.inactivity_timeout_seconds",
})?; })?;
let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false); let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
let mobile_appearance = raw
.ui
.mobile_appearance
.as_deref()
.map(MobileAppearance::from_config)
.transpose()?
.unwrap_or_default();
let mobile_tab = raw let mobile_tab = raw
.ui .ui
.selected_mobile_tab .selected_mobile_tab
@@ -1083,6 +1185,7 @@ fn validate_config(
clipboard_timeout, clipboard_timeout,
authentication_timeout, authentication_timeout,
biometric_unlock_enabled, biometric_unlock_enabled,
mobile_appearance,
mobile_tab, mobile_tab,
mobile_home_refreshed_at, mobile_home_refreshed_at,
watch_shared_totp_entries, watch_shared_totp_entries,
@@ -1270,6 +1373,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
"ui", "ui",
&[ &[
"selected_mobile_tab", "selected_mobile_tab",
"mobile_appearance",
"home_remote_refreshed_at_unix_seconds", "home_remote_refreshed_at_unix_seconds",
"watch_shared_totp_entries", "watch_shared_totp_entries",
], ],

View File

@@ -1,12 +1,13 @@
//! Shared mobile authentication state; Swift only supplies input and presents results. //! 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::{ use crate::{
authentication::{ authentication::{
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession, AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
NativeAuthenticationSession,
}, },
config::{Config, ConfigError}, config::{Config, ConfigError, GitRemote, MobileAppearance},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
@@ -27,7 +28,9 @@ use crate::{
repository::{ repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
}, },
secret_store::{SecretProtectionPolicy, SecretStoreError}, secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
},
write::{VaultWriter, WriteError}, write::{VaultWriter, WriteError},
}; };
@@ -129,6 +132,86 @@ pub struct MobileAuthenticationState {
remaining_seconds: u64, 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy { pub struct MobileEntryCopy {
value: String, value: String,
@@ -190,6 +273,8 @@ struct ActiveMobileLease {
struct MobileAuthenticationStatus { struct MobileAuthenticationStatus {
biometric_unlock_enabled: bool, biometric_unlock_enabled: bool,
authentication_timeout: AuthenticationTimeout,
appearance: MobileAppearance,
git_identity: GitIdentity, git_identity: GitIdentity,
active: Option<ActiveMobileLease>, active: Option<ActiveMobileLease>,
next_editor_id: u64, next_editor_id: u64,
@@ -256,6 +341,8 @@ impl MobileAuthentication {
Ok(Self { Ok(Self {
status: Mutex::new(MobileAuthenticationStatus { status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(), biometric_unlock_enabled: config.biometric_unlock_enabled(),
authentication_timeout: config.authentication_timeout(),
appearance: config.mobile_appearance(),
git_identity: config.git_identity().clone(), git_identity: config.git_identity().clone(),
active: None, active: None,
next_editor_id: 0, next_editor_id: 0,
@@ -430,6 +517,10 @@ impl MobileAuthentication {
Ok(self.status()?.git_identity.clone()) Ok(self.status()?.git_identity.clone())
} }
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationError> {
Ok(self.status()?.appearance)
}
pub fn set_git_identity( pub fn set_git_identity(
&self, &self,
name: String, name: String,
@@ -449,6 +540,112 @@ impl MobileAuthentication {
Ok(identity) 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> { pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
let status = self.status()?; let status = self.status()?;
let active = status.active.as_ref().ok_or_else(|| { 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 { fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
MobileAuthenticationError::new( MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry, MobileAuthenticationErrorKind::Entry,
@@ -1293,7 +1583,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict, MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
repository_operation_conflict, watch_preference,
}; };
#[test] #[test]
@@ -1319,4 +1610,19 @@ mod tests {
Some(MobileRepositoryOperationError::Busy) 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"));
}
} }

View File

@@ -557,6 +557,29 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) Ok(())
} }
/// Return only the non-secret account identity for one configured Git credential.
pub fn https_git_credential_account(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<String, SecretStoreError> {
self.retrieve_git_record(server, application)?
.reference
.account()
.map(str::to_owned)
.ok_or(SecretStoreError::Corrupted)
}
/// Remove the credential selected by its configured server/application identity.
pub fn delete_https_git_credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<(), SecretStoreError> {
let record = self.retrieve_git_record(server, application)?;
self.delete(&record.reference)
}
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> { pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
let locator = reference.locator(); let locator = reference.locator();
let mut state = self.unlocked_state()?; let mut state = self.unlocked_state()?;

View File

@@ -249,6 +249,30 @@ fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not()
Ok(()) Ok(())
} }
#[test]
fn timeout_updates_apply_to_the_active_lease_and_later_activity() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture_key(&fixture, &keys, "alice")?;
let backend = MemoryBackend::default();
provision_passphrase(
backend.clone(),
&alice,
fixture.key("alice")?.passphrase.as_bytes(),
)?;
let clock = ManualClock::default();
let session = session(backend, clock.clone(), 120)?;
let handle = session.authenticate(&alice)?;
clock.advance(Duration::from_secs(20));
session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?;
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
clock.advance(Duration::from_secs(10));
handle.touch_user_activity()?;
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
Ok(())
}
#[test] #[test]
fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult { fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;

View File

@@ -4,8 +4,10 @@ use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time:
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use ironstorage::{ use ironstorage::{
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, authentication::{
config::{ConfigError, ConfigLoader, EditorSource}, AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
},
config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance},
desktop::DesktopStorage, desktop::DesktopStorage,
git::GitIdentity, git::GitIdentity,
mobile::MobileTab, mobile::MobileTab,
@@ -299,6 +301,29 @@ fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult
Ok(()) Ok(())
} }
#[test]
fn mobile_timeout_and_appearance_share_the_secret_free_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(config.mobile_appearance(), MobileAppearance::System);
config.update_mobile_appearance(MobileAppearance::Dark)?;
config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?;
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark);
assert_eq!(
reloaded.authentication_timeout().duration(),
Duration::from_secs(300)
);
let contents = fs::read_to_string(fixture.explicit_path())?;
assert!(contents.contains("mobile_appearance = \"dark\""));
assert!(contents.contains("inactivity_timeout_seconds = 300"));
assert!(!contents.to_ascii_lowercase().contains("token ="));
Ok(())
}
#[test] #[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;

View File

@@ -11,7 +11,7 @@ use std::{
}; };
use ironstorage::{ use ironstorage::{
config::ConfigLoader, config::{ConfigLoader, GitRemote},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
git::{GitCredentialProvider as _, GitError}, git::{GitCredentialProvider as _, GitError},
repository::{EncryptedEntry, SecretBytes}, repository::{EncryptedEntry, SecretBytes},
@@ -325,6 +325,40 @@ fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResu
Ok(()) Ok(())
} }
#[test]
fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
let backend = MemoryBackend::default();
let store = SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
);
let remote = GitRemote::https(
"origin",
"https://git.example.test/alice/store.git",
"personal-git",
"ironstorage-mobile",
)?;
store.unlock()?;
store.store_https_git_credential(
remote.server_id(),
remote.application_id(),
"alice",
SecretBytes::new(b"private-token".to_vec()),
)?;
assert_eq!(
store.https_git_credential_account(remote.server_id(), remote.application_id())?,
"alice"
);
store.delete_https_git_credential(remote.server_id(), remote.application_id())?;
assert!(matches!(
store.https_git_credential_account(remote.server_id(), remote.application_id()),
Err(SecretStoreError::Missing)
));
assert!(!format!("{store:?}").contains("private-token"));
Ok(())
}
#[test] #[test]
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult { fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;

View File

@@ -32,6 +32,11 @@ clipboard_timeout_seconds = 45
# Optional; interactive frontends default to a two-minute inactivity lease. # Optional; interactive frontends default to a two-minute inactivity lease.
[security] [security]
inactivity_timeout_seconds = 120 inactivity_timeout_seconds = 120
biometric_unlock_enabled = false
# Optional iPhone appearance: "system", "light", or "dark".
[ui]
mobile_appearance = "system"
[git] [git]
user_name = "Alice Example" user_name = "Alice Example"
@@ -76,6 +81,11 @@ from 1 second through 24 hours. The storage crate owns deadline calculation and
relock; frontends report only genuine input events as user activity. Repaints, relock; frontends report only genuine input events as user activity. Repaints,
timers, background refresh, and Git work never extend the lease. timers, background refresh, and Git work never extend the lease.
`security.biometric_unlock_enabled` records only whether biometric unlock is
enabled; the GPG passphrase remains in protected system storage.
`ui.mobile_appearance` is shared, secret-free iPhone presentation state and
defaults to the system appearance.
Passwords, passphrases, tokens, credentials, private keys, and other secret Passwords, passphrases, tokens, credentials, private keys, and other secret
values are forbidden in TOML. Unknown fields are rejected. Parse errors never values are forbidden in TOML. Unknown fields are rejected. Parse errors never
echo the source line or value, so an accidentally supplied secret is not echo the source line or value, so an accidentally supplied secret is not