diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index a9b7235..ba9031f 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -585,18 +585,36 @@ fileprivate struct FfiConverterString: FfiConverter { public protocol MobileAuthenticationProtocol: AnyObject, Sendable { + func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String) throws -> MobileEntryEditorPage + + func beginCreateEntry(directory: String, name: String, passphrase: String?) throws -> MobileEntryEditorSession + + func beginEntryEditor(path: String) throws -> MobileEntryEditorSession + func cancel() throws func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy + func discardEntryEditor(editor: UInt64) throws + + func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage + func entryPage(path: String) throws -> MobileEntryPage + func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage + func manualLock() throws + func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage + + func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage + func replaceEntryField(path: String, field: UInt64, value: String) throws -> MobileEntryPage func revealEntryField(path: String, field: UInt64) throws -> String + func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryPage + func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func state() throws -> MobileAuthenticationState @@ -605,6 +623,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func unlockEntry(path: String, passphrase: String?) throws -> MobileAuthenticationState + func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryEditorPage + } open class MobileAuthentication: MobileAuthenticationProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -659,6 +679,41 @@ open class MobileAuthentication: MobileAuthenticationProtocol, @unchecked Sendab +open func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterTypeMobileEntryEditorFieldKind_lower(kind), + FfiConverterOptionString.lower(name), + FfiConverterString.lower(value),uniffiCallStatus + ) +}) +} + +open func beginCreateEntry(directory: String, name: String, passphrase: String?)throws -> MobileEntryEditorSession { + return try FfiConverterTypeMobileEntryEditorSession_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry( + self.uniffiCloneHandle(), + FfiConverterString.lower(directory), + FfiConverterString.lower(name), + FfiConverterOptionString.lower(passphrase),uniffiCallStatus + ) +}) +} + +open func beginEntryEditor(path: String)throws -> MobileEntryEditorSession { + return try FfiConverterTypeMobileEntryEditorSession_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_entry_editor( + self.uniffiCloneHandle(), + FfiConverterString.lower(path),uniffiCallStatus + ) +}) +} + open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel( @@ -678,6 +733,25 @@ open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy }) } +open func discardEntryEditor(editor: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor),uniffiCallStatus + ) +} +} + +open func entryEditor(editor: UInt64)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor),uniffiCallStatus + ) +}) +} + open func entryPage(path: String)throws -> MobileEntryPage { return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -688,6 +762,18 @@ open func entryPage(path: String)throws -> MobileEntryPage { }) } +open func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterOptionUInt32.lower(length), + FfiConverterBool.lower(noSymbols),uniffiCallStatus + ) +}) +} + open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock( @@ -696,6 +782,29 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth } } +open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterUInt64.lower(field),uniffiCallStatus + ) +}) +} + +open func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_reorder_entry_editor_field( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterUInt64.lower(field), + FfiConverterUInt32.lower(index),uniffiCallStatus + ) +}) +} + open func replaceEntryField(path: String, field: UInt64, value: String)throws -> MobileEntryPage { return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -719,6 +828,17 @@ open func revealEntryField(path: String, field: UInt64)throws -> String { }) } +open func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])throws -> MobileEntryPage { + return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterSequenceTypeMobileEntryEditorInput.lower(fields),uniffiCallStatus + ) +}) +} + open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -757,6 +877,17 @@ open func unlockEntry(path: String, passphrase: String?)throws -> MobileAuthent }) } +open func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterSequenceTypeMobileEntryEditorInput.lower(fields),uniffiCallStatus + ) +}) +} + } @@ -1239,6 +1370,290 @@ public func FfiConverterTypeMobileEntryCopy_lower(_ value: MobileEntryCopy) -> R } +public struct MobileEntryEditorField: Equatable, Hashable { + public var id: UInt64 + public var kind: MobileEntryEditorFieldKind + public var name: String? + public var label: String + public var systemImage: String + public var value: String? + public var maskedValue: String + public var diagnostic: String? + public var sensitive: Bool + public var multiline: Bool + public var nameEditable: Bool + public var removable: Bool + public var reorderable: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt64, kind: MobileEntryEditorFieldKind, name: String?, label: String, systemImage: String, value: String?, maskedValue: String, diagnostic: String?, sensitive: Bool, multiline: Bool, nameEditable: Bool, removable: Bool, reorderable: Bool) { + self.id = id + self.kind = kind + self.name = name + self.label = label + self.systemImage = systemImage + self.value = value + self.maskedValue = maskedValue + self.diagnostic = diagnostic + self.sensitive = sensitive + self.multiline = multiline + self.nameEditable = nameEditable + self.removable = removable + self.reorderable = reorderable + } + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorField: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorField: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorField { + return + try MobileEntryEditorField( + id: FfiConverterUInt64.read(from: &buf), + kind: FfiConverterTypeMobileEntryEditorFieldKind.read(from: &buf), + name: FfiConverterOptionString.read(from: &buf), + label: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + value: FfiConverterOptionString.read(from: &buf), + maskedValue: FfiConverterString.read(from: &buf), + diagnostic: FfiConverterOptionString.read(from: &buf), + sensitive: FfiConverterBool.read(from: &buf), + multiline: FfiConverterBool.read(from: &buf), + nameEditable: FfiConverterBool.read(from: &buf), + removable: FfiConverterBool.read(from: &buf), + reorderable: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryEditorField, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.id, into: &buf) + FfiConverterTypeMobileEntryEditorFieldKind.write(value.kind, into: &buf) + FfiConverterOptionString.write(value.name, into: &buf) + FfiConverterString.write(value.label, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterOptionString.write(value.value, into: &buf) + FfiConverterString.write(value.maskedValue, into: &buf) + FfiConverterOptionString.write(value.diagnostic, into: &buf) + FfiConverterBool.write(value.sensitive, into: &buf) + FfiConverterBool.write(value.multiline, into: &buf) + FfiConverterBool.write(value.nameEditable, into: &buf) + FfiConverterBool.write(value.removable, into: &buf) + FfiConverterBool.write(value.reorderable, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorField_lift(_ buf: RustBuffer) throws -> MobileEntryEditorField { + return try FfiConverterTypeMobileEntryEditorField.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorField_lower(_ value: MobileEntryEditorField) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorField.lower(value) +} + + +public struct MobileEntryEditorInput: Equatable, Hashable { + public var id: UInt64 + public var name: String? + public var value: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt64, name: String?, value: String?) { + self.id = id + self.name = name + self.value = value + } + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorInput: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorInput: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorInput { + return + try MobileEntryEditorInput( + id: FfiConverterUInt64.read(from: &buf), + name: FfiConverterOptionString.read(from: &buf), + value: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryEditorInput, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.id, into: &buf) + FfiConverterOptionString.write(value.name, into: &buf) + FfiConverterOptionString.write(value.value, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorInput_lift(_ buf: RustBuffer) throws -> MobileEntryEditorInput { + return try FfiConverterTypeMobileEntryEditorInput.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorInput_lower(_ value: MobileEntryEditorInput) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorInput.lower(value) +} + + +public struct MobileEntryEditorPage: Equatable, Hashable { + public var path: String + public var title: String + public var fields: [MobileEntryEditorField] + public var dirty: Bool + public var creating: Bool + public var defaultPasswordLength: UInt32 + public var maximumPasswordLength: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(path: String, title: String, fields: [MobileEntryEditorField], dirty: Bool, creating: Bool, defaultPasswordLength: UInt32, maximumPasswordLength: UInt32) { + self.path = path + self.title = title + self.fields = fields + self.dirty = dirty + self.creating = creating + self.defaultPasswordLength = defaultPasswordLength + self.maximumPasswordLength = maximumPasswordLength + } + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorPage { + return + try MobileEntryEditorPage( + path: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + fields: FfiConverterSequenceTypeMobileEntryEditorField.read(from: &buf), + dirty: FfiConverterBool.read(from: &buf), + creating: FfiConverterBool.read(from: &buf), + defaultPasswordLength: FfiConverterUInt32.read(from: &buf), + maximumPasswordLength: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryEditorPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterSequenceTypeMobileEntryEditorField.write(value.fields, into: &buf) + FfiConverterBool.write(value.dirty, into: &buf) + FfiConverterBool.write(value.creating, into: &buf) + FfiConverterUInt32.write(value.defaultPasswordLength, into: &buf) + FfiConverterUInt32.write(value.maximumPasswordLength, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorPage_lift(_ buf: RustBuffer) throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorPage_lower(_ value: MobileEntryEditorPage) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorPage.lower(value) +} + + +public struct MobileEntryEditorSession: Equatable, Hashable { + public var id: UInt64 + public var page: MobileEntryEditorPage + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt64, page: MobileEntryEditorPage) { + self.id = id + self.page = page + } + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorSession: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorSession: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorSession { + return + try MobileEntryEditorSession( + id: FfiConverterUInt64.read(from: &buf), + page: FfiConverterTypeMobileEntryEditorPage.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryEditorSession, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.id, into: &buf) + FfiConverterTypeMobileEntryEditorPage.write(value.page, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorSession_lift(_ buf: RustBuffer) throws -> MobileEntryEditorSession { + return try FfiConverterTypeMobileEntryEditorSession.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorSession_lower(_ value: MobileEntryEditorSession) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorSession.lower(value) +} + + public struct MobileEntryField: Equatable, Hashable { public var id: UInt64 public var label: String @@ -2279,6 +2694,7 @@ public enum MobileAuthenticationErrorKind: Equatable, Hashable { case entry case secureStorage case expired + case conflict @@ -2318,6 +2734,8 @@ public struct FfiConverterTypeMobileAuthenticationErrorKind: FfiConverterRustBuf case 9: return .expired + case 10: return .conflict + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -2361,6 +2779,10 @@ public struct FfiConverterTypeMobileAuthenticationErrorKind: FfiConverterRustBuf case .expired: writeInt(&buf, Int32(9)) + + case .conflict: + writeInt(&buf, Int32(10)) + } } } @@ -2462,6 +2884,86 @@ public func FfiConverterTypeMobileAuthenticationFfiError_lower(_ value: MobileAu +public enum MobileEntryEditorFieldKind: Equatable, Hashable { + + case password + case field + case note + case otpUri + + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorFieldKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorFieldKind: FfiConverterRustBuffer { + typealias SwiftType = MobileEntryEditorFieldKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorFieldKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .password + + case 2: return .field + + case 3: return .note + + case 4: return .otpUri + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileEntryEditorFieldKind, into buf: inout [UInt8]) { + switch value { + + + case .password: + writeInt(&buf, Int32(1)) + + + case .field: + writeInt(&buf, Int32(2)) + + + case .note: + writeInt(&buf, Int32(3)) + + + case .otpUri: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorFieldKind_lift(_ buf: RustBuffer) throws -> MobileEntryEditorFieldKind { + return try FfiConverterTypeMobileEntryEditorFieldKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorFieldKind_lower(_ value: MobileEntryEditorFieldKind) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorFieldKind.lower(value) +} + + + + public enum MobileEntrySectionKind: Equatable, Hashable { case password @@ -3809,6 +4311,30 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer { } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer { + typealias SwiftType = UInt32? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt32.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt32.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -3906,6 +4432,56 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileEntryEditorField: FfiConverterRustBuffer { + typealias SwiftType = [MobileEntryEditorField] + + public static func write(_ value: [MobileEntryEditorField], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileEntryEditorField.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntryEditorField] { + let len: Int32 = try readInt(&buf) + var seq = [MobileEntryEditorField]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileEntryEditorField.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileEntryEditorInput: FfiConverterRustBuffer { + typealias SwiftType = [MobileEntryEditorInput] + + public static func write(_ value: [MobileEntryEditorInput], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileEntryEditorInput.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntryEditorInput] { + let len: Int32 = try readInt(&buf) + var seq = [MobileEntryEditorInput]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileEntryEditorInput.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -4193,24 +4769,51 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field() != 58790) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_create_entry() != 21567) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_entry_editor() != 63281) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field() != 17773) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_discard_entry_editor() != 28195) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor() != 65179) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 56594) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_reorder_entry_editor_field() != 62124) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_replace_entry_field() != 15315) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_entry_field() != 13396) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor() != 30971) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } @@ -4223,6 +4826,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry() != 28139) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor() != 30139) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 7519be3..40097e7 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -253,6 +253,21 @@ uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle, void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry(uint64_t ptr, RustBuffer directory, RustBuffer name, RustBuffer passphrase, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_ENTRY_EDITOR +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_entry_editor(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -263,16 +278,41 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR +void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_EDITOR +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#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 +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REORDER_ENTRY_EDITOR_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REORDER_ENTRY_EDITOR_FIELD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reorder_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, uint32_t index, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_replace_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustBuffer value, RustCallStatus *_Nonnull out_status @@ -283,6 +323,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_replace_entry RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status +); +#endif #ifndef 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 @@ -303,6 +348,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(uint64_t ptr, RustBuffer path, RustBuffer passphrase, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -734,6 +784,24 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_create_entry(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_ENTRY_EDITOR +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_entry_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL @@ -746,18 +814,48 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(vo #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_discard_entry_editor(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_EDITOR +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password(void + ); #endif #ifndef 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 +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REORDER_ENTRY_EDITOR_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REORDER_ENTRY_EDITOR_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reorder_entry_editor_field(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD @@ -770,6 +868,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_replace_e #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_entry_field(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK @@ -794,6 +898,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_use #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index b9691a3..aa36dd5 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -896,6 +896,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi private var shellPage: MobilePage? private var directoryPage: MobilePasswordPage? private var loadTask: Task? + private var createTask: Task? private var loadGeneration = 0 init( @@ -943,6 +944,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi deinit { loadTask?.cancel() + createTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -1020,6 +1022,100 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi reload() } + @objc private func createRequested() { + let alert = UIAlertController( + title: "New Password", + message: "Enter a name for the password entry in this folder.", + preferredStyle: .alert + ) + alert.addTextField { field in + field.placeholder = "Entry Name" + field.autocapitalizationType = .words + field.clearButtonMode = .whileEditing + field.returnKeyType = .next + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in + guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return } + self?.beginCreate(name: name, passphrase: nil) + }) + present(alert, animated: true) + } + + private func beginCreate(name: String, passphrase: String?) { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + createTask?.cancel() + let directory = path ?? "" + createTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.beginCreateEntry( + directory: directory, + name: name, + passphrase: passphrase + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(session): + presentEditor(session, focusedField: nil) + case let .failure(failure) + where passphrase == nil + && (failure.kind == .passphraseRequired + || failure.kind == .biometryUnavailable): + promptForCreationPassphrase(name: name, message: failure.detail) + case let .failure(failure): + presentAuthenticationFailure(failure) + } + } + } + + private func promptForCreationPassphrase(name: String, message: String) { + let alert = UIAlertController( + title: "GPG Key Passphrase", + message: message, + preferredStyle: .alert + ) + alert.addTextField { field in + field.isSecureTextEntry = true + field.textContentType = .password + field.placeholder = "Passphrase" + field.returnKeyType = .go + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in + guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return } + alert?.textFields?.first?.text = nil + self?.beginCreate(name: name, passphrase: value) + }) + present(alert, animated: true) + } + + private func presentEditor(_ session: MobileEntryEditorSession, focusedField: UInt64?) { + guard let authentication else { return } + let editor = MobileEntryEditorViewController( + authentication: authentication, + session: session, + focusedField: focusedField + ) { [weak self] _ in + NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) + self?.reload() + } + let navigation = UINavigationController(rootViewController: editor) + navigation.modalPresentationStyle = .formSheet + present(navigation, animated: true) + } + private func reload() { loadGeneration += 1 let generation = loadGeneration @@ -1063,6 +1159,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi title = page.title guard page.state == .ready else { directoryPage = nil + navigationItem.rightBarButtonItem = nil tableView.reloadData() refreshControl?.endRefreshing() var configuration = page.state == .loading @@ -1088,6 +1185,12 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi case let .success(page): directoryPage = page title = page.title + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(createRequested) + ) + navigationItem.rightBarButtonItem?.accessibilityLabel = "Create password entry" contentUnavailableConfiguration = nil tableView.reloadData() if page.rows.isEmpty { @@ -1101,6 +1204,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none) } case let .failure(failure): + navigationItem.rightBarButtonItem = nil if failure.kind == .directoryMissing, path != nil { UIAccessibility.post( notification: .announcement, @@ -1436,32 +1540,15 @@ private final class LockedPasswordViewController: UITableViewController { } private func edit(_ field: MobileEntryField) { - performFieldAction(field) { authentication, path in - try authentication.revealEntryField(path: path, field: field.id) - } success: { [weak self] value in - guard let self else { return } - let editor = MobileEntryEditorViewController(field: field, value: value) { - [weak self] updated in self?.save(updated, for: field) - } - present(UINavigationController(rootViewController: editor), animated: true) - } - } - - private func save(_ value: String, for field: MobileEntryField) { guard let authentication else { return } entryTask?.cancel() - showLoading(title: "Saving Password") let path = entry.path entryTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { try authentication.touchUserActivity() - return Result.success( - try authentication.replaceEntryField( - path: path, - field: field.id, - value: value - ) + return Result.success( + try authentication.beginEntryEditor(path: path) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) @@ -1471,13 +1558,22 @@ private final class LockedPasswordViewController: UITableViewController { }.value guard !Task.isCancelled, let self else { return } switch result { - case let .success(page): - self.page = page - revealedValues.removeValue(forKey: field.id) - contentUnavailableConfiguration = nil - tableView.reloadData() - NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) - showFeedback("\(field.label) saved") + case let .success(session): + let editor = MobileEntryEditorViewController( + authentication: authentication, + session: session, + focusedField: field.id + ) { [weak self] _ in + NotificationCenter.default.post( + name: .ironStorageLocalStoreDidChange, + object: nil + ) + self?.revealedValues.removeAll(keepingCapacity: false) + self?.loadEntry() + } + let navigation = UINavigationController(rootViewController: editor) + navigation.modalPresentationStyle = .formSheet + present(navigation, animated: true) case let .failure(failure): handle(failure) } @@ -1742,17 +1838,37 @@ private final class MobileEntryFieldCell: UITableViewCell { } @MainActor -private final class MobileEntryEditorViewController: UIViewController { - private let field: MobileEntryField - private let valueView = UITextView() - private let save: (String) -> Void +private final class MobileEntryEditorViewController: UITableViewController, + UIAdaptivePresentationControllerDelegate +{ + private struct FieldState { + var name: String? + var value: String? + } - init(field: MobileEntryField, value: String, save: @escaping (String) -> Void) { - self.field = field - self.save = save - super.init(nibName: nil, bundle: nil) - valueView.text = value - title = "Edit \(field.label)" + private let authentication: MobileAuthentication + private var editorID: UInt64 + private var page: MobileEntryEditorPage + private let focusedField: UInt64? + private let saved: (String) -> Void + private var states: [UInt64: FieldState] = [:] + private var localDirty = false + private var focused = false + private var operationTask: Task? + + init( + authentication: MobileAuthentication, + session: MobileEntryEditorSession, + focusedField: UInt64?, + saved: @escaping (String) -> Void + ) { + self.authentication = authentication + editorID = session.id + page = session.page + self.focusedField = focusedField + self.saved = saved + super.init(style: .insetGrouped) + resetStates() } @available(*, unavailable) @@ -1760,34 +1876,590 @@ private final class MobileEntryEditorViewController: UIViewController { fatalError("init(coder:) is not supported") } + deinit { + operationTask?.cancel() + NotificationCenter.default.removeObserver(self) + } + override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemGroupedBackground - valueView.font = .preferredFont(forTextStyle: .body) - valueView.adjustsFontForContentSizeCategory = true - valueView.autocorrectionType = field.sensitive ? .no : .default - valueView.autocapitalizationType = field.sensitive ? .none : .sentences - valueView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(valueView) - NSLayoutConstraint.activate([ - valueView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - valueView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - valueView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - valueView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16), - ]) + title = page.creating ? "New \(page.title)" : "Edit \(page.title)" + navigationItem.largeTitleDisplayMode = .never + tableView.register(MobileEntryEditorCell.self, forCellReuseIdentifier: "EditorField") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 120 + tableView.setEditing(true, animated: false) navigationItem.leftBarButtonItem = UIBarButtonItem( systemItem: .cancel, - primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) } + primaryAction: UIAction { [weak self] _ in self?.cancelRequested() } ) navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .save, - primaryAction: UIAction { [weak self] _ in - guard let self else { return } - let value = valueView.text ?? "" - dismiss(animated: true) { self.save(value) } - } + primaryAction: UIAction { [weak self] _ in self?.saveRequested() } ) - valueView.becomeFirstResponder() + let add = UIBarButtonItem( + systemItem: .add, + primaryAction: UIAction { [weak self] _ in self?.addRequested() } + ) + add.accessibilityLabel = "Add entry field" + let generate = UIBarButtonItem( + title: "Generate Password", + image: UIImage(systemName: "wand.and.stars"), + primaryAction: UIAction { [weak self] _ in self?.generateRequested() } + ) + toolbarItems = [ + add, + UIBarButtonItem(systemItem: .flexibleSpace), + generate, + ] + NotificationCenter.default.addObserver( + self, + selector: #selector(authenticationDidChange), + name: .ironStorageAuthenticationDidChange, + object: nil + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setToolbarHidden(false, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.presentationController?.delegate = self + guard !focused, let focusedField, + let index = page.fields.firstIndex(where: { $0.id == focusedField }) + else { return } + focused = true + let path = IndexPath(row: index, section: 0) + tableView.scrollToRow(at: path, at: .middle, animated: true) + (tableView.cellForRow(at: path) as? MobileEntryEditorCell)?.focusValue() + } + + override func numberOfSections(in tableView: UITableView) -> Int { 1 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + page.fields.count + } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + "Entry Fields" + } + + override func tableView( + _ tableView: UITableView, + titleForFooterInSection section: Int + ) -> String? { + "Drag the reorder controls to preserve the exact field order saved by Rust." + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: "EditorField", + for: indexPath + ) as? MobileEntryEditorCell else { return UITableViewCell() } + let field = page.fields[indexPath.row] + let state = states[field.id] ?? FieldState(name: field.name, value: field.value) + cell.configure(field: field, name: state.name, value: state.value) { [weak self] name, value in + self?.states[field.id] = FieldState(name: name, value: value) + self?.localDirty = true + } + cell.showsReorderControl = field.reorderable + return cell + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + page.fields[indexPath.row].removable + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + page.fields[indexPath.row].reorderable + } + + override func tableView( + _ tableView: UITableView, + editingStyleForRowAt indexPath: IndexPath + ) -> UITableViewCell.EditingStyle { + page.fields[indexPath.row].removable ? .delete : .none + } + + override func tableView( + _ tableView: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { + guard editingStyle == .delete else { return } + do { + try synchronizeDraft() + page = try authentication.removeEntryEditorField( + editor: editorID, + field: page.fields[indexPath.row].id + ) + resetStates() + tableView.deleteRows(at: [indexPath], with: .automatic) + } catch { + presentEditorFailure(error) + tableView.reloadData() + } + } + + override func tableView( + _ tableView: UITableView, + moveRowAt sourceIndexPath: IndexPath, + to destinationIndexPath: IndexPath + ) { + let field = page.fields[sourceIndexPath.row] + do { + try synchronizeDraft() + page = try authentication.reorderEntryEditorField( + editor: editorID, + field: field.id, + index: UInt32(destinationIndexPath.row) + ) + resetStates() + tableView.reloadData() + } catch { + presentEditorFailure(error) + tableView.reloadData() + } + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + guard isDirty else { + try? authentication.discardEntryEditor(editor: editorID) + return true + } + return false + } + + func presentationControllerDidAttemptToDismiss( + _ presentationController: UIPresentationController + ) { + confirmDiscard() + } + + @objc private func authenticationDidChange() { + guard (try? authentication.state().unlocked) != true else { return } + states = states.mapValues { _ in FieldState(name: nil, value: nil) } + tableView.visibleCells + .compactMap { $0 as? MobileEntryEditorCell } + .forEach { $0.clearSensitiveContent() } + dismiss(animated: true) + UIAccessibility.post(notification: .announcement, argument: "Entry editor locked") + } + + private var isDirty: Bool { localDirty || page.dirty } + + private func resetStates() { + states = Dictionary(uniqueKeysWithValues: page.fields.map { + ($0.id, FieldState(name: $0.name, value: $0.value)) + }) + localDirty = false + } + + private func inputs() -> [MobileEntryEditorInput] { + page.fields.map { field in + let state = states[field.id] ?? FieldState(name: field.name, value: field.value) + return MobileEntryEditorInput(id: field.id, name: state.name, value: state.value) + } + } + + private func synchronizeDraft() throws { + view.endEditing(true) + try authentication.touchUserActivity() + page = try authentication.updateEntryEditor(editor: editorID, fields: inputs()) + resetStates() + } + + private func cancelRequested() { + if isDirty { + confirmDiscard() + } else { + discardAndDismiss() + } + } + + private func confirmDiscard() { + let sheet = UIAlertController( + title: "Discard Changes?", + message: "Unsaved entry changes will be lost.", + preferredStyle: .actionSheet + ) + sheet.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) { + [weak self] _ in self?.discardAndDismiss() + }) + sheet.addAction(UIAlertAction(title: "Keep Editing", style: .cancel)) + sheet.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem + present(sheet, animated: true) + } + + private func discardAndDismiss() { + try? authentication.discardEntryEditor(editor: editorID) + states.removeAll(keepingCapacity: false) + dismiss(animated: true) + } + + private func addRequested() { + let sheet = UIAlertController(title: "Add Field", message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Named Field", style: .default) { [weak self] _ in + self?.promptForNamedField() + }) + sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in + self?.addField(kind: .note, name: nil, value: "") + }) + sheet.addAction(UIAlertAction(title: "OTP URI", style: .default) { [weak self] _ in + self?.promptForOtpUri() + }) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + sheet.popoverPresentationController?.barButtonItem = toolbarItems?.first + present(sheet, animated: true) + } + + private func promptForNamedField() { + let alert = UIAlertController( + title: "Named Field", + message: "Enter a field name. You can enter its value in the form.", + preferredStyle: .alert + ) + alert.addTextField { field in + field.placeholder = "Field Name" + field.autocapitalizationType = .none + field.autocorrectionType = .no + field.clearButtonMode = .whileEditing + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in + guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return } + self?.addField(kind: .field, name: name, value: "") + }) + present(alert, animated: true) + } + + private func promptForOtpUri() { + let alert = UIAlertController( + title: "OTP URI", + message: "Enter the complete otpauth URI.", + preferredStyle: .alert + ) + alert.addTextField { field in + field.placeholder = "otpauth://…" + field.keyboardType = .URL + field.autocapitalizationType = .none + field.autocorrectionType = .no + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in + guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return } + self?.addField(kind: .otpUri, name: nil, value: value) + }) + present(alert, animated: true) + } + + private func addField(kind: MobileEntryEditorFieldKind, name: String?, value: String) { + do { + try synchronizeDraft() + page = try authentication.addEntryEditorField( + editor: editorID, + kind: kind, + name: name, + value: value + ) + resetStates() + tableView.reloadData() + if !page.fields.isEmpty { + let path = IndexPath(row: page.fields.count - 1, section: 0) + tableView.scrollToRow(at: path, at: .bottom, animated: true) + } + } catch { + presentEditorFailure(error) + } + } + + private func generateRequested() { + let alert = UIAlertController( + title: "Generate Password", + message: "Choose a validated length from 1 through \(page.maximumPasswordLength).", + preferredStyle: .alert + ) + alert.addTextField { [defaultLength = page.defaultPasswordLength] field in + field.text = String(defaultLength) + field.keyboardType = .numberPad + field.clearButtonMode = .whileEditing + field.accessibilityLabel = "Password length" + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "With Symbols", style: .default) { [weak self, weak alert] _ in + self?.generate(from: alert, noSymbols: false) + }) + alert.addAction(UIAlertAction(title: "Letters & Numbers", style: .default) { + [weak self, weak alert] _ in self?.generate(from: alert, noSymbols: true) + }) + present(alert, animated: true) + } + + private func generate(from alert: UIAlertController?, noSymbols: Bool) { + guard let text = alert?.textFields?.first?.text, + let length = UInt32(text) + else { + let failure = UIAlertController( + title: "Invalid Password Length", + message: "Enter a whole number from 1 through \(page.maximumPasswordLength).", + preferredStyle: .alert + ) + failure.addAction(UIAlertAction(title: "OK", style: .default)) + present(failure, animated: true) + return + } + do { + try synchronizeDraft() + page = try authentication.generateEntryEditorPassword( + editor: editorID, + length: length, + noSymbols: noSymbols + ) + resetStates() + tableView.reloadData() + UIAccessibility.post(notification: .announcement, argument: "Password generated") + } catch { + presentEditorFailure(error) + } + } + + private func saveRequested() { + view.endEditing(true) + let fields = inputs() + let authentication = authentication + let editorID = editorID + navigationItem.leftBarButtonItem?.isEnabled = false + navigationItem.rightBarButtonItem?.isEnabled = false + operationTask?.cancel() + operationTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.saveEntryEditor(editor: editorID, fields: fields) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + navigationItem.leftBarButtonItem?.isEnabled = true + navigationItem.rightBarButtonItem?.isEnabled = true + switch result { + case let .success(savedPage): + states.removeAll(keepingCapacity: false) + dismiss(animated: true) { self.saved(savedPage.id) } + case let .failure(failure): + presentSaveFailure(failure) + } + } + } + + private func presentSaveFailure(_ failure: AuthenticationFailure) { + let alert = UIAlertController( + title: failure.title, + message: failure.detail, + preferredStyle: .alert + ) + if failure.kind == .conflict { + alert.addAction(UIAlertAction(title: "Reload Latest", style: .destructive) { + [weak self] _ in self?.reloadLatest() + }) + } + alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel)) + present(alert, animated: true) + } + + private func reloadLatest() { + let path = page.path + try? authentication.discardEntryEditor(editor: editorID) + do { + let session = try authentication.beginEntryEditor(path: path) + editorID = session.id + page = session.page + resetStates() + tableView.reloadData() + } catch { + presentEditorFailure(error) + } + } + + private func presentEditorFailure(_ error: Error) { + let failure = (error as? MobileAuthenticationFfiError).map(AuthenticationFailure.init) + ?? .unexpected + presentAuthenticationFailure(failure) + } +} + +@MainActor +private final class MobileEntryEditorCell: UITableViewCell, UITextViewDelegate { + private let iconView = UIImageView() + private let labelView = UILabel() + private let nameField = UITextField() + private let valueField = UITextField() + private let valueView = UITextView() + private let diagnosticView = UILabel() + private let revealButton = UIButton(type: .system) + private var changed: ((String?, String?) -> Void)? + private var currentName: String? + private var currentValue: String? + private var sensitive = false + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + iconView.tintColor = .secondaryLabel + iconView.translatesAutoresizingMaskIntoConstraints = false + labelView.font = .preferredFont(forTextStyle: .caption1) + labelView.textColor = .secondaryLabel + labelView.adjustsFontForContentSizeCategory = true + for field in [nameField, valueField] { + field.font = .preferredFont(forTextStyle: .body) + field.adjustsFontForContentSizeCategory = true + field.clearButtonMode = .whileEditing + field.addTarget(self, action: #selector(textFieldChanged(_:)), for: .editingChanged) + } + nameField.placeholder = "Field Name" + nameField.autocapitalizationType = .none + nameField.autocorrectionType = .no + valueView.font = .preferredFont(forTextStyle: .body) + valueView.adjustsFontForContentSizeCategory = true + valueView.backgroundColor = .clear + valueView.isScrollEnabled = false + valueView.textContainerInset = .zero + valueView.textContainer.lineFragmentPadding = 0 + valueView.delegate = self + diagnosticView.font = .preferredFont(forTextStyle: .footnote) + diagnosticView.textColor = .systemOrange + diagnosticView.adjustsFontForContentSizeCategory = true + diagnosticView.numberOfLines = 0 + revealButton.configuration = .plain() + revealButton.configuration?.image = UIImage(systemName: "eye") + revealButton.addAction(UIAction { [weak self] _ in self?.toggleReveal() }, for: .touchUpInside) + let heading = UIStackView(arrangedSubviews: [labelView, nameField]) + heading.axis = .vertical + heading.spacing = 2 + let body = UIStackView(arrangedSubviews: [heading, valueField, valueView, diagnosticView]) + body.axis = .vertical + body.spacing = 6 + let row = UIStackView(arrangedSubviews: [iconView, body, revealButton]) + row.alignment = .top + row.spacing = 12 + row.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(row) + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + revealButton.widthAnchor.constraint(equalToConstant: 44), + revealButton.heightAnchor.constraint(equalToConstant: 44), + valueView.heightAnchor.constraint(greaterThanOrEqualToConstant: 72), + row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func configure( + field: MobileEntryEditorField, + name: String?, + value: String?, + changed: @escaping (String?, String?) -> Void + ) { + self.changed = changed + currentName = name + currentValue = value + sensitive = field.sensitive + iconView.image = UIImage(systemName: field.systemImage) + labelView.text = field.label + nameField.text = name + nameField.isHidden = !field.nameEditable + labelView.isHidden = field.nameEditable + let multiline = field.multiline || field.kind == .note + valueField.isHidden = multiline + valueView.isHidden = !multiline + valueField.text = value ?? field.maskedValue + valueView.text = value ?? field.maskedValue + let editable = value != nil + nameField.isEnabled = editable + valueField.isEnabled = editable + valueView.isEditable = editable + valueField.isSecureTextEntry = field.sensitive + valueField.textContentType = field.kind == .password ? .password : nil + valueField.keyboardType = field.kind == .otpUri ? .URL : .default + valueField.autocapitalizationType = field.sensitive ? .none : .sentences + valueField.autocorrectionType = field.sensitive ? .no : .default + valueView.autocapitalizationType = .sentences + diagnosticView.text = field.diagnostic + diagnosticView.isHidden = field.diagnostic == nil + revealButton.isHidden = !field.sensitive + revealButton.accessibilityLabel = "Reveal \(field.label)" + nameField.accessibilityLabel = "\(field.label) name" + valueField.accessibilityLabel = field.label + valueView.accessibilityLabel = field.label + } + + func focusValue() { + if valueView.isHidden { + valueField.becomeFirstResponder() + } else { + valueView.becomeFirstResponder() + } + } + + func clearSensitiveContent() { + currentValue = nil + valueField.text = nil + valueView.text = nil + } + + override func prepareForReuse() { + super.prepareForReuse() + changed = nil + currentName = nil + currentValue = nil + valueField.isSecureTextEntry = false + } + + @objc private func textFieldChanged(_ sender: UITextField) { + if sender === nameField { + currentName = sender.text + } else { + currentValue = sender.text + } + changed?(currentName, currentValue) + } + + func textViewDidChange(_ textView: UITextView) { + currentValue = textView.text + changed?(currentName, currentValue) + } + + private func toggleReveal() { + guard sensitive else { return } + valueField.isSecureTextEntry.toggle() + revealButton.configuration?.image = UIImage( + systemName: valueField.isSecureTextEntry ? "eye" : "eye.slash" + ) + revealButton.accessibilityLabel = valueField.isSecureTextEntry + ? "Reveal field" + : "Hide field" } } diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index eb3bbb8..a5a0c69 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -15,7 +15,11 @@ use ironstorage::{ MobileEntryCopy as StorageEntryCopy, }, mobile_entry::{ - MobileEntryPage as StorageEntryPage, MobileEntrySectionKind as StorageEntrySectionKind, + MobileEntryEditorFieldKind as StorageEntryEditorFieldKind, + MobileEntryEditorInput as StorageEntryEditorInput, + MobileEntryEditorPage as StorageEntryEditorPage, + MobileEntryEditorSession as StorageEntryEditorSession, MobileEntryPage as StorageEntryPage, + MobileEntrySectionKind as StorageEntrySectionKind, }, mobile_home::{ self, MobileHomeChangeKind as StorageHomeChangeKind, @@ -511,6 +515,7 @@ pub enum MobileAuthenticationErrorKind { Entry, SecureStorage, Expired, + Conflict, } impl From for MobileAuthenticationErrorKind { @@ -525,6 +530,7 @@ impl From for MobileAuthenticationErrorKind { StorageAuthenticationErrorKind::Entry => Self::Entry, StorageAuthenticationErrorKind::SecureStorage => Self::SecureStorage, StorageAuthenticationErrorKind::Expired => Self::Expired, + StorageAuthenticationErrorKind::Conflict => Self::Conflict, } } } @@ -624,6 +630,124 @@ pub struct MobileEntryCopy { pub timeout_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileEntryEditorFieldKind { + Password, + Field, + Note, + OtpUri, +} + +impl From for MobileEntryEditorFieldKind { + fn from(kind: StorageEntryEditorFieldKind) -> Self { + match kind { + StorageEntryEditorFieldKind::Password => Self::Password, + StorageEntryEditorFieldKind::Field => Self::Field, + StorageEntryEditorFieldKind::Note => Self::Note, + StorageEntryEditorFieldKind::OtpUri => Self::OtpUri, + } + } +} + +impl From for StorageEntryEditorFieldKind { + fn from(kind: MobileEntryEditorFieldKind) -> Self { + match kind { + MobileEntryEditorFieldKind::Password => Self::Password, + MobileEntryEditorFieldKind::Field => Self::Field, + MobileEntryEditorFieldKind::Note => Self::Note, + MobileEntryEditorFieldKind::OtpUri => Self::OtpUri, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryEditorField { + pub id: u64, + pub kind: MobileEntryEditorFieldKind, + pub name: Option, + pub label: String, + pub system_image: String, + pub value: Option, + pub masked_value: String, + pub diagnostic: Option, + pub sensitive: bool, + pub multiline: bool, + pub name_editable: bool, + pub removable: bool, + pub reorderable: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryEditorPage { + pub path: String, + pub title: String, + pub fields: Vec, + pub dirty: bool, + pub creating: bool, + pub default_password_length: u32, + pub maximum_password_length: u32, +} + +impl From<&StorageEntryEditorPage> for MobileEntryEditorPage { + fn from(page: &StorageEntryEditorPage) -> Self { + Self { + path: page.path().to_owned(), + title: page.title().to_owned(), + fields: page + .fields() + .iter() + .map(|field| MobileEntryEditorField { + id: field.id(), + kind: field.kind().into(), + name: field.name().map(str::to_owned), + label: field.label().to_owned(), + system_image: field.system_image().to_owned(), + value: field.value().map(str::to_owned), + masked_value: field.masked_value().to_owned(), + diagnostic: field.diagnostic().map(str::to_owned), + sensitive: field.sensitive(), + multiline: field.multiline(), + name_editable: field.name_editable(), + removable: field.removable(), + reorderable: field.reorderable(), + }) + .collect(), + dirty: page.dirty(), + creating: page.creating(), + default_password_length: page.default_password_length(), + maximum_password_length: page.maximum_password_length(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryEditorSession { + pub id: u64, + pub page: MobileEntryEditorPage, +} + +impl From for MobileEntryEditorSession { + fn from(session: StorageEntryEditorSession) -> Self { + Self { + id: session.id(), + page: session.page().into(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryEditorInput { + pub id: u64, + pub name: Option, + pub value: Option, +} + +impl From for StorageEntryEditorInput { + fn from(input: MobileEntryEditorInput) -> Self { + Self::new(input.id, input.name, input.value) + } +} + impl From for MobileEntryCopy { fn from(copy: StorageEntryCopy) -> Self { Self { @@ -760,6 +884,119 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn begin_entry_editor( + &self, + path: String, + ) -> Result { + self.authentication + .begin_entry_editor(&path) + .map(Into::into) + .map_err(Into::into) + } + + pub fn begin_create_entry( + &self, + directory: String, + name: String, + passphrase: Option, + ) -> Result { + self.authentication + .begin_create_entry( + &directory, + &name, + passphrase + .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), + ) + .map(Into::into) + .map_err(Into::into) + } + + pub fn entry_editor( + &self, + editor: u64, + ) -> Result { + self.authentication + .entry_editor(editor) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn update_entry_editor( + &self, + editor: u64, + fields: Vec, + ) -> Result { + self.authentication + .update_entry_editor(editor, fields.into_iter().map(Into::into).collect()) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn add_entry_editor_field( + &self, + editor: u64, + kind: MobileEntryEditorFieldKind, + name: Option, + value: String, + ) -> Result { + self.authentication + .add_entry_editor_field(editor, kind.into(), name, value) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn remove_entry_editor_field( + &self, + editor: u64, + field: u64, + ) -> Result { + self.authentication + .remove_entry_editor_field(editor, field) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn reorder_entry_editor_field( + &self, + editor: u64, + field: u64, + index: u32, + ) -> Result { + self.authentication + .reorder_entry_editor_field(editor, field, index as usize) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn generate_entry_editor_password( + &self, + editor: u64, + length: Option, + no_symbols: bool, + ) -> Result { + self.authentication + .generate_entry_editor_password(editor, length, no_symbols) + .map(|page| (&page).into()) + .map_err(Into::into) + } + + pub fn save_entry_editor( + &self, + editor: u64, + fields: Vec, + ) -> Result { + self.authentication + .save_entry_editor(editor, fields.into_iter().map(Into::into).collect()) + .map(Into::into) + .map_err(Into::into) + } + + pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .discard_entry_editor(editor) + .map_err(Into::into) + } + pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> { self.authentication.manual_lock().map_err(Into::into) } diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index fa82cd8..3112cd1 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -320,6 +320,10 @@ impl EntryDocument { self.conflict_token } + pub fn is_modified(&self) -> bool { + self.modified + } + pub fn add( &mut self, index: usize, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index a2732c8..1e36ba6 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -1,6 +1,6 @@ //! Shared mobile authentication state; Swift only supplies input and presents results. -use std::{error::Error, fmt, sync::Mutex}; +use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex}; use crate::{ authentication::{ @@ -10,9 +10,17 @@ use crate::{ crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, git::{AutomaticEntryCommitter, GitIdentity}, - mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value}, - repository::{EntryPath, Repository, RepositoryError, SecretBytes}, + mobile_entry::{ + MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, + MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage, + MobileEntryValueError, field_value, + }, + recipient::RecipientPolicyManager, + repository::{ + DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, + }, secret_store::{SecretProtectionPolicy, SecretStoreError}, + write::{VaultWriter, WriteError}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -26,6 +34,7 @@ pub enum MobileAuthenticationErrorKind { Entry, SecureStorage, Expired, + Conflict, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -150,6 +159,8 @@ struct ActiveMobileLease { struct MobileAuthenticationStatus { biometric_unlock_enabled: bool, active: Option, + next_editor_id: u64, + editors: BTreeMap, } /// One process-wide mobile authentication lease shared by every tab and viewer. @@ -181,6 +192,8 @@ impl MobileAuthentication { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), active: None, + next_editor_id: 0, + editors: BTreeMap::new(), }), config, repository, @@ -197,17 +210,26 @@ impl MobileAuthentication { ) -> Result { let entry = EntryPath::parse(path).map_err(entry_error)?; let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?; - let candidates = self.keys.decrypting_keys(&ciphertext).map_err(key_error)?; + self.unlock_ciphertext(&ciphertext, passphrase) + } + + fn unlock_ciphertext( + &self, + ciphertext: &EncryptedEntry, + passphrase: Option, + ) -> Result { + let candidates = self.keys.decrypting_keys(ciphertext).map_err(key_error)?; if let Some(active) = self.take_active()? { let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key); - if self.keys.decrypt(&ciphertext, &mut provider).is_ok() { + if self.keys.decrypt(ciphertext, &mut provider).is_ok() { self.restore_active(active)?; return self.state(); } self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; + self.status()?.editors.clear(); } let biometric_enabled = self.status()?.biometric_unlock_enabled; @@ -220,7 +242,7 @@ impl MobileAuthentication { .authenticate_with_passphrase(key, candidate) .map_err(MobileAuthenticationError::authentication)?; let mut provider = KeyOnlyProvider::new(handle.clone(), key); - match self.keys.decrypt(&ciphertext, &mut provider) { + match self.keys.decrypt(ciphertext, &mut provider) { Ok(plaintext) => { drop(plaintext); if biometric_enabled { @@ -257,7 +279,7 @@ impl MobileAuthentication { Err(_) => continue, }; let mut provider = KeyOnlyProvider::new(handle.clone(), key); - if let Ok(plaintext) = self.keys.decrypt(&ciphertext, &mut provider) { + if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) { drop(plaintext); self.set_active(handle, key.clone())?; return self.state(); @@ -312,6 +334,7 @@ impl MobileAuthentication { let mut status = self.status()?; status.biometric_unlock_enabled = false; status.active = None; + status.editors.clear(); } self.state() } @@ -324,6 +347,7 @@ impl MobileAuthentication { let mut status = self.status()?; if remaining.is_none() { status.active = None; + status.editors.clear(); } Ok(MobileAuthenticationState { unlocked: status.active.is_some(), @@ -391,11 +415,191 @@ impl MobileAuthentication { Ok(MobileEntryPage::from_document(&document)) } + pub fn begin_entry_editor( + &self, + path: &str, + ) -> Result { + let document = self.open_active_document(path)?; + self.store_editor(MobileEntryDraft::new(document, false)) + } + + pub fn begin_create_entry( + &self, + directory: &str, + name: &str, + passphrase: Option, + ) -> Result { + let directory = DirectoryPath::parse(directory).map_err(entry_error)?; + let leaf = EntryPath::parse(name).map_err(entry_error)?; + if leaf + .as_path() + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()) + { + return Err(entry_detail( + "Entry Name Is Invalid", + "enter a name without a folder separator", + )); + } + let path = + EntryPath::parse(directory.as_path().join(leaf.as_path())).map_err(entry_error)?; + let path_text = path.to_string(); + if VaultWriter::new(&self.repository, &self.keys) + .entry_exists(&path_text) + .map_err(|error| entry_detail("Password Entry Is Unavailable", error))? + { + return Err(entry_detail( + "Password Already Exists", + "Choose a different entry name.", + )); + } + let recipients = RecipientPolicyManager::new(&self.repository, &self.keys) + .resolve_for_entry(&path, None) + .map_err(|error| entry_detail("Recipients Are Unavailable", error))?; + let probe = self + .keys + .encrypt( + SecretBytes::new(b"IronStorage mobile entry creation".to_vec()), + recipients.recipients(), + ) + .map_err(key_error)?; + self.unlock_ciphertext(&probe, passphrase)?; + let mut document = self.open_active_document(&path_text)?; + document + .add( + 0, + crate::document::EntryFieldDraft::line(Vec::new()).map_err(document_error)?, + ) + .map_err(document_error)?; + self.store_editor(MobileEntryDraft::new(document, true)) + } + + pub fn entry_editor( + &self, + editor: u64, + ) -> Result { + self.ensure_active()?; + self.status()? + .editors + .get(&editor) + .map(MobileEntryDraft::page) + .ok_or_else(editor_missing) + } + + pub fn update_entry_editor( + &self, + editor: u64, + fields: Vec, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft.apply(fields).map_err(editor_error)?; + Ok(draft.page()) + } + + pub fn add_entry_editor_field( + &self, + editor: u64, + kind: MobileEntryEditorFieldKind, + name: Option, + value: String, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft.add(kind, name, value).map_err(editor_error)?; + Ok(draft.page()) + } + + pub fn remove_entry_editor_field( + &self, + editor: u64, + field: u64, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft.remove(field).map_err(editor_error)?; + Ok(draft.page()) + } + + pub fn reorder_entry_editor_field( + &self, + editor: u64, + field: u64, + index: usize, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft.reorder(field, index).map_err(editor_error)?; + Ok(draft.page()) + } + + pub fn generate_entry_editor_password( + &self, + editor: u64, + length: Option, + no_symbols: bool, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft + .generate_password(length, no_symbols) + .map_err(editor_error)?; + Ok(draft.page()) + } + + pub fn save_entry_editor( + &self, + editor: u64, + fields: Vec, + ) -> Result { + self.ensure_active()?; + let mut draft = self + .status()? + .editors + .remove(&editor) + .ok_or_else(editor_missing)?; + if let Err(error) = draft.apply(fields) { + self.restore_editor(editor, draft)?; + return Err(editor_error(error)); + } + let path = draft.document().path().to_string(); + let mut committer = match AutomaticEntryCommitter::for_entry( + &self.repository, + &path, + GitIdentity::ironstorage(), + ) { + Ok(committer) => committer, + Err(error) => { + self.restore_editor(editor, draft)?; + return Err(entry_detail("Password Entry Could Not Be Saved", error)); + } + }; + if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys) + .save_recoverable(draft.document(), None, &mut committer) + { + self.restore_editor(editor, draft)?; + return Err(document_error(error)); + } + Ok(MobileEntryPage::from_document(draft.document())) + } + + pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> { + self.status()?.editors.remove(&editor); + Ok(()) + } + pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; - self.status()?.active = None; + let mut status = self.status()?; + status.active = None; + status.editors.clear(); Ok(()) } @@ -403,7 +607,9 @@ impl MobileAuthentication { self.session .cancel() .map_err(MobileAuthenticationError::authentication)?; - self.status()?.active = None; + let mut status = self.status()?; + status.active = None; + status.editors.clear(); Ok(()) } @@ -447,6 +653,48 @@ impl MobileAuthentication { Ok(()) } + fn ensure_active(&self) -> Result<(), MobileAuthenticationError> { + let handle = self + .status()? + .active + .as_ref() + .map(|active| active.handle.clone()) + .ok_or_else(locked_error)?; + if let Err(error) = handle.ensure_active() { + let mut status = self.status()?; + status.active = None; + status.editors.clear(); + return Err(MobileAuthenticationError::authentication(error)); + } + Ok(()) + } + + fn store_editor( + &self, + draft: MobileEntryDraft, + ) -> Result { + let mut status = self.status()?; + let id = status.next_editor_id; + status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| { + entry_detail( + "Entry Editor Is Unavailable", + "entry editor identifiers are exhausted", + ) + })?; + let page = draft.page(); + status.editors.insert(id, draft); + Ok(MobileEntryEditorSession::new(id, page)) + } + + fn restore_editor( + &self, + editor: u64, + draft: MobileEntryDraft, + ) -> Result<(), MobileAuthenticationError> { + self.status()?.editors.insert(editor, draft); + Ok(()) + } + fn open_active_document(&self, path: &str) -> Result { let (handle, key) = { let status = self.status()?; @@ -519,13 +767,32 @@ fn locked_error() -> MobileAuthenticationError { } fn document_error(error: DocumentError) -> MobileAuthenticationError { - entry_detail("Password Entry Is Unavailable", error) + let kind = if matches!( + &error, + DocumentError::Write(WriteError::ConcurrentModification { .. }) + ) { + MobileAuthenticationErrorKind::Conflict + } else { + MobileAuthenticationErrorKind::Entry + }; + MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string()) } fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError { entry_detail("Field Value Is Unavailable", error) } +fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError { + entry_detail("Entry Draft Is Invalid", error) +} + +fn editor_missing() -> MobileAuthenticationError { + entry_detail( + "Entry Draft Is Unavailable", + "the entry draft was discarded or locked", + ) +} + fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs index 407b37f..03feb27 100644 --- a/crates/storage/src/mobile_entry.rs +++ b/crates/storage/src/mobile_entry.rs @@ -1,11 +1,12 @@ //! Storage-owned projection of lossless entry documents for native mobile viewers. -use std::{error::Error, fmt}; +use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize}; use crate::document::{ - DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind, - EntrySensitivity, + DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldDraft, EntryFieldId, + EntryFieldKind, EntrySensitivity, }; +use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileEntrySectionKind { @@ -104,6 +105,352 @@ pub struct MobileEntryPage { sections: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileEntryEditorFieldKind { + Password, + Field, + Note, + OtpUri, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryEditorField { + id: u64, + kind: MobileEntryEditorFieldKind, + name: Option, + label: String, + system_image: String, + value: Option, + masked_value: String, + diagnostic: Option, + sensitive: bool, + multiline: bool, + name_editable: bool, + removable: bool, + reorderable: bool, +} + +impl MobileEntryEditorField { + pub fn id(&self) -> u64 { + self.id + } + + pub fn kind(&self) -> MobileEntryEditorFieldKind { + self.kind + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn label(&self) -> &str { + &self.label + } + + pub fn system_image(&self) -> &str { + &self.system_image + } + + pub fn value(&self) -> Option<&str> { + self.value.as_deref() + } + + pub fn masked_value(&self) -> &str { + &self.masked_value + } + + pub fn diagnostic(&self) -> Option<&str> { + self.diagnostic.as_deref() + } + + pub fn sensitive(&self) -> bool { + self.sensitive + } + + pub fn multiline(&self) -> bool { + self.multiline + } + + pub fn name_editable(&self) -> bool { + self.name_editable + } + + pub fn removable(&self) -> bool { + self.removable + } + + pub fn reorderable(&self) -> bool { + self.reorderable + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryEditorPage { + path: String, + title: String, + fields: Vec, + dirty: bool, + creating: bool, + default_password_length: u32, + maximum_password_length: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryEditorSession { + id: u64, + page: MobileEntryEditorPage, +} + +impl MobileEntryEditorSession { + pub fn new(id: u64, page: MobileEntryEditorPage) -> Self { + Self { id, page } + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn page(&self) -> &MobileEntryEditorPage { + &self.page + } +} + +impl MobileEntryEditorPage { + pub fn path(&self) -> &str { + &self.path + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn fields(&self) -> &[MobileEntryEditorField] { + &self.fields + } + + pub fn dirty(&self) -> bool { + self.dirty + } + + pub fn creating(&self) -> bool { + self.creating + } + + pub fn default_password_length(&self) -> u32 { + self.default_password_length + } + + pub fn maximum_password_length(&self) -> u32 { + self.maximum_password_length + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryEditorInput { + id: u64, + name: Option, + value: Option, +} + +impl MobileEntryEditorInput { + pub fn new(id: u64, name: Option, value: Option) -> Self { + Self { id, name, value } + } +} + +pub struct MobileEntryDraft { + document: EntryDocument, + creating: bool, +} + +impl MobileEntryDraft { + pub fn new(document: EntryDocument, creating: bool) -> Self { + Self { document, creating } + } + + pub fn page(&self) -> MobileEntryEditorPage { + let fields = self + .document + .fields() + .iter() + .enumerate() + .map(|(index, field)| editor_field(index, field)) + .collect(); + MobileEntryEditorPage { + path: self.document.path().to_string(), + title: self + .document + .path() + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Password") + .to_owned(), + fields, + dirty: self.document.is_modified(), + creating: self.creating, + default_password_length: GeneratorConfig::pass_defaults().default_length().get() as u32, + maximum_password_length: MAX_PASSWORD_LENGTH as u32, + } + } + + pub fn apply( + &mut self, + inputs: Vec, + ) -> Result<(), MobileEntryEditorError> { + if inputs.len() != self.document.fields().len() { + return Err(MobileEntryEditorError::FieldSetChanged); + } + let mut seen = BTreeSet::new(); + let mut replacements = Vec::with_capacity(inputs.len()); + for input in inputs { + if !seen.insert(input.id) { + return Err(MobileEntryEditorError::FieldSetChanged); + } + let id = EntryFieldId::from_value(input.id); + let field = self + .document + .field(id) + .ok_or(MobileEntryEditorError::FieldSetChanged)?; + if let Some(value) = input.value { + replacements.push((id, editor_draft(field, input.name, value)?)); + } else if field.metadata().diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value) { + return Err(MobileEntryEditorError::NonUtf8); + } + } + for (id, replacement) in replacements { + self.document.update(id, replacement)?; + } + Ok(()) + } + + pub fn add( + &mut self, + kind: MobileEntryEditorFieldKind, + name: Option, + value: String, + ) -> Result<(), MobileEntryEditorError> { + let draft = match kind { + MobileEntryEditorFieldKind::Password => { + return Err(MobileEntryEditorError::ProtectedPasswordField); + } + MobileEntryEditorFieldKind::Field => EntryFieldDraft::field( + name.ok_or(MobileEntryEditorError::MissingFieldName)?, + value.into_bytes(), + )?, + MobileEntryEditorFieldKind::Note => { + EntryFieldDraft::field("notes", value.into_bytes())? + } + MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?, + }; + self.document.add(self.document.fields().len(), draft)?; + Ok(()) + } + + pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> { + let id = EntryFieldId::from_value(id); + if self + .document + .fields() + .first() + .is_some_and(|field| field.id() == id) + { + return Err(MobileEntryEditorError::ProtectedPasswordField); + } + self.document.remove(id)?; + Ok(()) + } + + pub fn reorder(&mut self, id: u64, index: usize) -> Result<(), MobileEntryEditorError> { + let id = EntryFieldId::from_value(id); + if index == 0 + || self + .document + .fields() + .first() + .is_some_and(|field| field.id() == id) + { + return Err(MobileEntryEditorError::ProtectedPasswordField); + } + self.document.reorder(id, index)?; + Ok(()) + } + + pub fn generate_password( + &mut self, + length: Option, + no_symbols: bool, + ) -> Result<(), MobileEntryEditorError> { + let length = length + .map(|length| { + usize::try_from(length) + .ok() + .and_then(NonZeroUsize::new) + .ok_or(MobileEntryEditorError::Generate( + GenerateError::InvalidLength, + )) + }) + .transpose()?; + let generated = GeneratorConfig::pass_defaults().generate_secret(length, no_symbols)?; + let value = generated.expose().to_vec(); + if let Some(password) = self + .document + .fields() + .iter() + .find(|field| field.metadata().kind() == EntryFieldKind::Password) + { + self.document + .update(password.id(), EntryFieldDraft::line(value)?)?; + } else { + self.document.add(0, EntryFieldDraft::line(value)?)?; + } + Ok(()) + } + + pub fn document(&self) -> &EntryDocument { + &self.document + } +} + +#[derive(Debug)] +pub enum MobileEntryEditorError { + Document(DocumentError), + Generate(GenerateError), + FieldSetChanged, + MissingFieldName, + ProtectedPasswordField, + NonUtf8, +} + +impl fmt::Display for MobileEntryEditorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Document(error) => error.fmt(formatter), + Self::Generate(error) => error.fmt(formatter), + Self::FieldSetChanged => formatter.write_str("the entry fields changed while editing"), + Self::MissingFieldName => formatter.write_str("a named field requires a name"), + Self::ProtectedPasswordField => { + formatter.write_str("the first password field cannot be removed or moved") + } + Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"), + } + } +} + +impl Error for MobileEntryEditorError {} + +impl From for MobileEntryEditorError { + fn from(error: DocumentError) -> Self { + Self::Document(error) + } +} + +impl From for MobileEntryEditorError { + fn from(error: GenerateError) -> Self { + Self::Generate(error) + } +} + impl MobileEntryPage { pub fn from_document(document: &EntryDocument) -> Self { let title = document @@ -213,12 +560,71 @@ fn mobile_field(field: &EntryField) -> MobileEntryField { } } +fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField { + let metadata = field.metadata(); + let kind = if is_named_note(field) { + MobileEntryEditorFieldKind::Note + } else { + match metadata.kind() { + EntryFieldKind::Password => MobileEntryEditorFieldKind::Password, + EntryFieldKind::OtpUri => MobileEntryEditorFieldKind::OtpUri, + EntryFieldKind::Note => MobileEntryEditorFieldKind::Note, + EntryFieldKind::Username + | EntryFieldKind::Email + | EntryFieldKind::Url + | EntryFieldKind::Field => MobileEntryEditorFieldKind::Field, + } + }; + let sensitive = matches!( + kind, + MobileEntryEditorFieldKind::Password | MobileEntryEditorFieldKind::OtpUri + ); + MobileEntryEditorField { + id: field.id().value(), + kind, + name: metadata.name().map(str::to_owned), + label: label(field), + system_image: system_image(metadata.kind()).to_owned(), + value: String::from_utf8(field.value().to_vec()).ok(), + masked_value: if metadata.sensitivity() == EntrySensitivity::Empty { + "Empty" + } else if sensitive { + "Hidden" + } else { + "Unavailable" + } + .to_owned(), + diagnostic: metadata.diagnostic().map(diagnostic), + sensitive, + multiline: matches!(kind, MobileEntryEditorFieldKind::Note) + || field.value().contains(&b'\n'), + name_editable: matches!(kind, MobileEntryEditorFieldKind::Field), + removable: index > 0, + reorderable: index > 0, + } +} + +fn editor_draft( + field: &EntryField, + name: Option, + value: String, +) -> Result { + Ok(match field.metadata().kind() { + EntryFieldKind::Password => EntryFieldDraft::line(value.into_bytes())?, + EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?, + EntryFieldKind::Note => EntryFieldDraft::multiline(value.into_bytes()), + EntryFieldKind::Username + | EntryFieldKind::Email + | EntryFieldKind::Url + | EntryFieldKind::Field => EntryFieldDraft::field( + name.ok_or(MobileEntryEditorError::MissingFieldName)?, + value.into_bytes(), + )?, + }) +} + fn section_kind(field: &EntryField) -> MobileEntrySectionKind { - if field.metadata().name().is_some_and(|name| { - ["comment", "comments", "note", "notes"] - .iter() - .any(|candidate| name.eq_ignore_ascii_case(candidate)) - }) { + if is_named_note(field) { return MobileEntrySectionKind::Notes; } match field.metadata().kind() { @@ -232,6 +638,14 @@ fn section_kind(field: &EntryField) -> MobileEntrySectionKind { } } +fn is_named_note(field: &EntryField) -> bool { + field.metadata().name().is_some_and(|name| { + ["comment", "comments", "note", "notes"] + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) + }) +} + fn label(field: &EntryField) -> String { if field.metadata().kind() == EntryFieldKind::Password { return "Password".to_owned(); diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index 5a64a21..10c967e 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -9,7 +9,10 @@ use ironstorage::{ document::{ DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity, }, - mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value}, + mobile_entry::{ + MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, + MobileEntryEditorInput, MobileEntryPage, MobileEntrySectionKind, field_value, + }, recipient::RecipientPolicyManager, repository::{EntryPath, Repository, SecretBytes}, write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError}, @@ -624,6 +627,133 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult { Ok(()) } +#[test] +fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + write_plaintext( + &repository, + &keys, + "documents/mobile-editor", + b"old password\nlogin: alice\ncomments: first\nsecond\nnote: repeated one\nnote: repeated two\nbinary: \xff\n", + )?; + let service = EntryDocumentService::new(&repository, &keys); + let document = service.open("documents/mobile-editor", &mut secrets)?; + let mut draft = MobileEntryDraft::new(document, false); + let page = draft.page(); + assert!(!page.dirty()); + assert!(!page.creating()); + assert_eq!(page.default_password_length(), 25); + assert_eq!(page.fields().len(), 6); + assert!(page.fields()[2].multiline()); + assert_eq!(page.fields()[2].value(), Some("first\nsecond")); + assert!(page.fields()[5].value().is_none()); + + let password = page.fields()[0].id(); + assert!(matches!( + draft.remove(password), + Err(MobileEntryEditorError::ProtectedPasswordField) + )); + assert!(matches!( + draft.reorder(password, 1), + Err(MobileEntryEditorError::ProtectedPasswordField) + )); + assert!(draft.generate_password(Some(0), false).is_err()); + + let repeated_one = page.fields()[3].id(); + let inputs = page + .fields() + .iter() + .map(|field| { + let (name, value) = if field.label() == "Login" { + (Some("username".to_owned()), Some("bob".to_owned())) + } else if field.label() == "Comments" { + ( + field.name().map(str::to_owned), + Some("heading\nline: preserved\n\nend".to_owned()), + ) + } else { + ( + field.name().map(str::to_owned), + field.value().map(str::to_owned), + ) + }; + MobileEntryEditorInput::new(field.id(), name, value) + }) + .collect(); + draft.apply(inputs)?; + draft.add( + MobileEntryEditorFieldKind::Field, + Some("tags".to_owned()), + "work, mobile".to_owned(), + )?; + draft.add( + MobileEntryEditorFieldKind::Note, + None, + "open note\nsecond line".to_owned(), + )?; + draft.remove(repeated_one)?; + let open_note = draft.page().fields().last().expect("open note").id(); + draft.reorder(open_note, 2)?; + draft.generate_password(Some(32), true)?; + + let edited = draft.page(); + assert!(edited.dirty()); + let password = edited.fields().first().expect("password").value().unwrap(); + assert_eq!(password.len(), 32); + assert!( + password + .chars() + .all(|character| character.is_ascii_alphanumeric()) + ); + assert_eq!(edited.fields()[2].value(), Some("open note\nsecond line")); + + let mut committer = Committer::default(); + service.save_recoverable(draft.document(), None, &mut committer)?; + let reopened = service.open("documents/mobile-editor", &mut secrets)?; + assert_eq!( + reopened + .fields() + .iter() + .filter(|field| field.metadata().name() == Some("note")) + .count(), + 1 + ); + assert!( + reopened.fields().iter().any(|field| { + field.metadata().name() == Some("username") && field.value() == b"bob" + }) + ); + assert!(reopened.fields().iter().any(|field| { + field.metadata().name() == Some("comments") + && field.value() == b"heading\nline: preserved\n\nend" + })); + assert!(reopened.fields().iter().any(|field| { + field.metadata().name() == Some("tags") && field.value() == b"work, mobile" + })); + assert!( + reopened + .fields() + .iter() + .any(|field| { field.metadata().name() == Some("binary") && field.value() == b"\xff" }) + ); + + let mut created = service.open("documents/cancelled-mobile", &mut secrets)?; + created.add(0, EntryFieldDraft::line(Vec::new())?)?; + let created = MobileEntryDraft::new(created, true); + assert!(created.page().creating()); + assert!(created.page().dirty()); + drop(created); + assert!(matches!( + repository.read_entry(&EntryPath::parse("documents/cancelled-mobile")?), + Err(ironstorage::repository::RepositoryError::NotFound { .. }) + )); + Ok(()) +} + #[test] fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { let fixture = FixtureSet::load()?;