Implement structured iPhone entry viewer
This commit is contained in:
@@ -587,8 +587,16 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func cancel() throws
|
func cancel() throws
|
||||||
|
|
||||||
|
func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy
|
||||||
|
|
||||||
|
func entryPage(path: String) throws -> MobileEntryPage
|
||||||
|
|
||||||
func manualLock() throws
|
func manualLock() throws
|
||||||
|
|
||||||
|
func replaceEntryField(path: String, field: UInt64, value: String) throws -> MobileEntryPage
|
||||||
|
|
||||||
|
func revealEntryField(path: String, field: UInt64) throws -> String
|
||||||
|
|
||||||
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
||||||
|
|
||||||
func state() throws -> MobileAuthenticationState
|
func state() throws -> MobileAuthenticationState
|
||||||
@@ -659,6 +667,27 @@ open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy {
|
||||||
|
return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),
|
||||||
|
FfiConverterUInt64.lower(field),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
open func entryPage(path: String)throws -> MobileEntryPage {
|
||||||
|
return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(
|
||||||
@@ -667,6 +696,29 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func replaceEntryField(path: String, field: UInt64, value: String)throws -> MobileEntryPage {
|
||||||
|
return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_replace_entry_field(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),
|
||||||
|
FfiConverterUInt64.lower(field),
|
||||||
|
FfiConverterString.lower(value),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
open func revealEntryField(path: String, field: UInt64)throws -> String {
|
||||||
|
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_field(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),
|
||||||
|
FfiConverterUInt64.lower(field),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
|
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
|
||||||
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -1133,6 +1185,266 @@ public func FfiConverterTypeMobileAuthenticationState_lower(_ value: MobileAuthe
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileEntryCopy: Equatable, Hashable {
|
||||||
|
public var value: String
|
||||||
|
public var timeoutSeconds: UInt64
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(value: String, timeoutSeconds: UInt64) {
|
||||||
|
self.value = value
|
||||||
|
self.timeoutSeconds = timeoutSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileEntryCopy: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileEntryCopy: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryCopy {
|
||||||
|
return
|
||||||
|
try MobileEntryCopy(
|
||||||
|
value: FfiConverterString.read(from: &buf),
|
||||||
|
timeoutSeconds: FfiConverterUInt64.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileEntryCopy, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterString.write(value.value, into: &buf)
|
||||||
|
FfiConverterUInt64.write(value.timeoutSeconds, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryCopy_lift(_ buf: RustBuffer) throws -> MobileEntryCopy {
|
||||||
|
return try FfiConverterTypeMobileEntryCopy.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryCopy_lower(_ value: MobileEntryCopy) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileEntryCopy.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileEntryField: Equatable, Hashable {
|
||||||
|
public var id: UInt64
|
||||||
|
public var label: String
|
||||||
|
public var systemImage: String
|
||||||
|
public var value: String?
|
||||||
|
public var maskedValue: String
|
||||||
|
public var detail: String?
|
||||||
|
public var diagnostic: String?
|
||||||
|
public var sensitive: Bool
|
||||||
|
public var multiline: Bool
|
||||||
|
public var selectable: Bool
|
||||||
|
public var editable: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(id: UInt64, label: String, systemImage: String, value: String?, maskedValue: String, detail: String?, diagnostic: String?, sensitive: Bool, multiline: Bool, selectable: Bool, editable: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.label = label
|
||||||
|
self.systemImage = systemImage
|
||||||
|
self.value = value
|
||||||
|
self.maskedValue = maskedValue
|
||||||
|
self.detail = detail
|
||||||
|
self.diagnostic = diagnostic
|
||||||
|
self.sensitive = sensitive
|
||||||
|
self.multiline = multiline
|
||||||
|
self.selectable = selectable
|
||||||
|
self.editable = editable
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileEntryField: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileEntryField: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryField {
|
||||||
|
return
|
||||||
|
try MobileEntryField(
|
||||||
|
id: FfiConverterUInt64.read(from: &buf),
|
||||||
|
label: FfiConverterString.read(from: &buf),
|
||||||
|
systemImage: FfiConverterString.read(from: &buf),
|
||||||
|
value: FfiConverterOptionString.read(from: &buf),
|
||||||
|
maskedValue: FfiConverterString.read(from: &buf),
|
||||||
|
detail: FfiConverterOptionString.read(from: &buf),
|
||||||
|
diagnostic: FfiConverterOptionString.read(from: &buf),
|
||||||
|
sensitive: FfiConverterBool.read(from: &buf),
|
||||||
|
multiline: FfiConverterBool.read(from: &buf),
|
||||||
|
selectable: FfiConverterBool.read(from: &buf),
|
||||||
|
editable: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileEntryField, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterUInt64.write(value.id, 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.detail, into: &buf)
|
||||||
|
FfiConverterOptionString.write(value.diagnostic, into: &buf)
|
||||||
|
FfiConverterBool.write(value.sensitive, into: &buf)
|
||||||
|
FfiConverterBool.write(value.multiline, into: &buf)
|
||||||
|
FfiConverterBool.write(value.selectable, into: &buf)
|
||||||
|
FfiConverterBool.write(value.editable, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryField_lift(_ buf: RustBuffer) throws -> MobileEntryField {
|
||||||
|
return try FfiConverterTypeMobileEntryField.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryField_lower(_ value: MobileEntryField) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileEntryField.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileEntryPage: Equatable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String
|
||||||
|
public var sections: [MobileEntrySection]
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(id: String, title: String, sections: [MobileEntrySection]) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.sections = sections
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileEntryPage: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileEntryPage: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryPage {
|
||||||
|
return
|
||||||
|
try MobileEntryPage(
|
||||||
|
id: FfiConverterString.read(from: &buf),
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
sections: FfiConverterSequenceTypeMobileEntrySection.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileEntryPage, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterString.write(value.id, into: &buf)
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterSequenceTypeMobileEntrySection.write(value.sections, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryPage_lift(_ buf: RustBuffer) throws -> MobileEntryPage {
|
||||||
|
return try FfiConverterTypeMobileEntryPage.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntryPage_lower(_ value: MobileEntryPage) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileEntryPage.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileEntrySection: Equatable, Hashable {
|
||||||
|
public var kind: MobileEntrySectionKind
|
||||||
|
public var title: String
|
||||||
|
public var fields: [MobileEntryField]
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(kind: MobileEntrySectionKind, title: String, fields: [MobileEntryField]) {
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.fields = fields
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileEntrySection: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileEntrySection: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntrySection {
|
||||||
|
return
|
||||||
|
try MobileEntrySection(
|
||||||
|
kind: FfiConverterTypeMobileEntrySectionKind.read(from: &buf),
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
fields: FfiConverterSequenceTypeMobileEntryField.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileEntrySection, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterTypeMobileEntrySectionKind.write(value.kind, into: &buf)
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterSequenceTypeMobileEntryField.write(value.fields, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntrySection_lift(_ buf: RustBuffer) throws -> MobileEntrySection {
|
||||||
|
return try FfiConverterTypeMobileEntrySection.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntrySection_lower(_ value: MobileEntrySection) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileEntrySection.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct MobileHomeChange: Equatable, Hashable {
|
public struct MobileHomeChange: Equatable, Hashable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String
|
public var title: String
|
||||||
@@ -2150,6 +2462,86 @@ public func FfiConverterTypeMobileAuthenticationFfiError_lower(_ value: MobileAu
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public enum MobileEntrySectionKind: Equatable, Hashable {
|
||||||
|
|
||||||
|
case password
|
||||||
|
case details
|
||||||
|
case oneTimePassword
|
||||||
|
case notes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileEntrySectionKind: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileEntrySectionKind: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = MobileEntrySectionKind
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntrySectionKind {
|
||||||
|
let variant: Int32 = try readInt(&buf)
|
||||||
|
switch variant {
|
||||||
|
|
||||||
|
case 1: return .password
|
||||||
|
|
||||||
|
case 2: return .details
|
||||||
|
|
||||||
|
case 3: return .oneTimePassword
|
||||||
|
|
||||||
|
case 4: return .notes
|
||||||
|
|
||||||
|
default: throw UniffiInternalError.unexpectedEnumCase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileEntrySectionKind, into buf: inout [UInt8]) {
|
||||||
|
switch value {
|
||||||
|
|
||||||
|
|
||||||
|
case .password:
|
||||||
|
writeInt(&buf, Int32(1))
|
||||||
|
|
||||||
|
|
||||||
|
case .details:
|
||||||
|
writeInt(&buf, Int32(2))
|
||||||
|
|
||||||
|
|
||||||
|
case .oneTimePassword:
|
||||||
|
writeInt(&buf, Int32(3))
|
||||||
|
|
||||||
|
|
||||||
|
case .notes:
|
||||||
|
writeInt(&buf, Int32(4))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntrySectionKind_lift(_ buf: RustBuffer) throws -> MobileEntrySectionKind {
|
||||||
|
return try FfiConverterTypeMobileEntrySectionKind.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileEntrySectionKind_lower(_ value: MobileEntrySectionKind) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileEntrySectionKind.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public enum MobileHomeChangeKind: Equatable, Hashable {
|
public enum MobileHomeChangeKind: Equatable, Hashable {
|
||||||
|
|
||||||
case passwordEntry
|
case passwordEntry
|
||||||
@@ -3514,6 +3906,56 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceTypeMobileEntryField: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [MobileEntryField]
|
||||||
|
|
||||||
|
public static func write(_ value: [MobileEntryField], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterTypeMobileEntryField.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntryField] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [MobileEntryField]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterTypeMobileEntryField.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceTypeMobileEntrySection: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [MobileEntrySection]
|
||||||
|
|
||||||
|
public static func write(_ value: [MobileEntrySection], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterTypeMobileEntrySection.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntrySection] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [MobileEntrySection]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterTypeMobileEntrySection.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -3754,9 +4196,21 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field() != 17773) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 56594) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_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_set_biometric_unlock() != 9486) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,11 +258,31 @@ void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, Rust
|
|||||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
||||||
|
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_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_MANUAL_LOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
#define 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
|
void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_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
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
||||||
|
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_SET_BIOMETRIC_UNLOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||||
@@ -720,12 +740,36 @@ uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(void
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
||||||
|
#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_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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_replace_entry_field(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
||||||
|
#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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
self.window = window
|
self.window = window
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
context?.lockForBackground()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -73,6 +77,15 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
|||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockForBackground() {
|
||||||
|
guard let authentication else { return }
|
||||||
|
try? authentication.manualLock()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func tabBarController(
|
func tabBarController(
|
||||||
_ tabBarController: UITabBarController,
|
_ tabBarController: UITabBarController,
|
||||||
didSelect viewController: UIViewController
|
didSelect viewController: UIViewController
|
||||||
@@ -275,7 +288,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: false)
|
||||||
guard shellTab == .home, let homePage else { return }
|
guard shellTab == .home, let homePage else { return }
|
||||||
let section = homeSections[indexPath.section]
|
let section = homeSections[indexPath.section]
|
||||||
let commit: MobileHomeCommit
|
let commit: MobileHomeCommit
|
||||||
@@ -1142,20 +1155,21 @@ private struct PasswordFailure: Error, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class LockedPasswordViewController: UIViewController {
|
private final class LockedPasswordViewController: UITableViewController {
|
||||||
private let entry: MobilePasswordRow
|
private let entry: MobilePasswordRow
|
||||||
private let authentication: MobileAuthentication?
|
private let authentication: MobileAuthentication?
|
||||||
private let imageView = UIImageView()
|
|
||||||
private let titleLabel = UILabel()
|
|
||||||
private let detailLabel = UILabel()
|
|
||||||
private let unlockButton = UIButton(type: .system)
|
|
||||||
private var unlockTask: Task<Void, Never>?
|
private var unlockTask: Task<Void, Never>?
|
||||||
|
private var entryTask: Task<Void, Never>?
|
||||||
|
private var clipboardTask: Task<Void, Never>?
|
||||||
|
private var feedbackTask: Task<Void, Never>?
|
||||||
private var state: MobileAuthenticationState?
|
private var state: MobileAuthenticationState?
|
||||||
|
private var page: MobileEntryPage?
|
||||||
|
private var revealedValues: [UInt64: String] = [:]
|
||||||
|
|
||||||
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.authentication = authentication
|
self.authentication = authentication
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(style: .insetGrouped)
|
||||||
title = entry.title
|
title = entry.title
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
}
|
}
|
||||||
@@ -1167,34 +1181,9 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemGroupedBackground
|
tableView.register(MobileEntryFieldCell.self, forCellReuseIdentifier: "EntryField")
|
||||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
imageView.tintColor = .secondaryLabel
|
tableView.estimatedRowHeight = 92
|
||||||
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
|
||||||
titleLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
titleLabel.textAlignment = .center
|
|
||||||
detailLabel.font = .preferredFont(forTextStyle: .body)
|
|
||||||
detailLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
detailLabel.textAlignment = .center
|
|
||||||
detailLabel.textColor = .secondaryLabel
|
|
||||||
detailLabel.numberOfLines = 0
|
|
||||||
unlockButton.configuration = .filled()
|
|
||||||
unlockButton.configuration?.cornerStyle = .capsule
|
|
||||||
unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside)
|
|
||||||
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton])
|
|
||||||
stack.axis = .vertical
|
|
||||||
stack.alignment = .center
|
|
||||||
stack.spacing = 12
|
|
||||||
stack.setCustomSpacing(24, after: detailLabel)
|
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(stack)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
|
||||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
|
||||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor),
|
|
||||||
detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
|
|
||||||
unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
|
|
||||||
])
|
|
||||||
view.accessibilityIdentifier = entry.id
|
view.accessibilityIdentifier = entry.id
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
@@ -1202,11 +1191,14 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
name: .ironStorageAuthenticationDidChange,
|
name: .ironStorageAuthenticationDidChange,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
render()
|
refreshState()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
unlockTask?.cancel()
|
unlockTask?.cancel()
|
||||||
|
entryTask?.cancel()
|
||||||
|
clipboardTask?.cancel()
|
||||||
|
feedbackTask?.cancel()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,23 +1207,59 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
refreshState()
|
refreshState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
page?.sections.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
numberOfRowsInSection section: Int
|
||||||
|
) -> Int {
|
||||||
|
page?.sections[section].fields.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
page?.sections[section].title
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
guard
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: "EntryField",
|
||||||
|
for: indexPath
|
||||||
|
) as? MobileEntryFieldCell,
|
||||||
|
let field = field(at: indexPath)
|
||||||
|
else { return UITableViewCell() }
|
||||||
|
cell.configure(
|
||||||
|
field: field,
|
||||||
|
revealedValue: revealedValues[field.id],
|
||||||
|
copy: { [weak self] tappedCell in self?.copy(field, in: tappedCell) },
|
||||||
|
reveal: { [weak self] in self?.revealOrHide(field) },
|
||||||
|
edit: { [weak self] in self?.edit(field) }
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard
|
||||||
|
let field = field(at: indexPath),
|
||||||
|
let cell = tableView.cellForRow(at: indexPath) as? MobileEntryFieldCell
|
||||||
|
else { return }
|
||||||
|
copy(field, in: cell)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func authenticationDidChange() {
|
@objc private func authenticationDidChange() {
|
||||||
refreshState()
|
refreshState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func unlockRequested() {
|
@objc private func unlockRequested() {
|
||||||
if state?.unlocked == true, let authentication {
|
|
||||||
do {
|
|
||||||
try authentication.touchUserActivity()
|
|
||||||
refreshState()
|
|
||||||
UIAccessibility.post(notification: .announcement, argument: "Unlock extended")
|
|
||||||
} catch let error as MobileAuthenticationFfiError {
|
|
||||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
|
||||||
} catch {
|
|
||||||
presentAuthenticationFailure(.unexpected)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unlock(passphrase: nil)
|
unlock(passphrase: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,7 +1272,6 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
name: .ironStorageAuthenticationDidChange,
|
name: .ironStorageAuthenticationDidChange,
|
||||||
object: authentication
|
object: authentication
|
||||||
)
|
)
|
||||||
render()
|
|
||||||
} catch let error as MobileAuthenticationFfiError {
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1254,7 +1281,12 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
|
|
||||||
private func refreshState() {
|
private func refreshState() {
|
||||||
state = try? authentication?.state()
|
state = try? authentication?.state()
|
||||||
render()
|
if state?.unlocked == true {
|
||||||
|
if page == nil { loadEntry() }
|
||||||
|
} else {
|
||||||
|
maskAndDiscardEntry()
|
||||||
|
showLocked()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unlock(passphrase: String?) {
|
private func unlock(passphrase: String?) {
|
||||||
@@ -1263,8 +1295,7 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
unlockTask?.cancel()
|
unlockTask?.cancel()
|
||||||
unlockButton.isEnabled = false
|
showLoading(title: "Unlocking Password")
|
||||||
unlockButton.configuration?.showsActivityIndicator = true
|
|
||||||
let path = entry.path
|
let path = entry.path
|
||||||
unlockTask = Task { [weak self] in
|
unlockTask = Task { [weak self] in
|
||||||
let result = await Task.detached(priority: .userInitiated) {
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
@@ -1279,8 +1310,6 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}.value
|
}.value
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
unlockButton.isEnabled = true
|
|
||||||
unlockButton.configuration?.showsActivityIndicator = false
|
|
||||||
switch result {
|
switch result {
|
||||||
case let .success(state):
|
case let .success(state):
|
||||||
self.state = state
|
self.state = state
|
||||||
@@ -1288,7 +1317,7 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
name: .ironStorageAuthenticationDidChange,
|
name: .ironStorageAuthenticationDidChange,
|
||||||
object: authentication
|
object: authentication
|
||||||
)
|
)
|
||||||
render()
|
loadEntry()
|
||||||
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
|
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
|
||||||
case let .failure(failure):
|
case let .failure(failure):
|
||||||
if passphrase == nil,
|
if passphrase == nil,
|
||||||
@@ -1296,6 +1325,7 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
promptForPassphrase(message: failure.detail)
|
promptForPassphrase(message: failure.detail)
|
||||||
} else if failure.kind != .cancelled {
|
} else if failure.kind != .cancelled {
|
||||||
presentAuthenticationFailure(failure)
|
presentAuthenticationFailure(failure)
|
||||||
|
showLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1324,28 +1354,440 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func render() {
|
private func loadEntry() {
|
||||||
let unlocked = state?.unlocked == true
|
guard let authentication else {
|
||||||
imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
|
showLocked()
|
||||||
titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password"
|
return
|
||||||
detailLabel.text = if unlocked {
|
|
||||||
"The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds."
|
|
||||||
} else {
|
|
||||||
"Authenticate to unlock this password entry. Browsing remains available while locked."
|
|
||||||
}
|
}
|
||||||
unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock"
|
entryTask?.cancel()
|
||||||
navigationItem.rightBarButtonItem = unlocked
|
showLoading(title: "Opening Password")
|
||||||
? UIBarButtonItem(
|
let path = entry.path
|
||||||
image: UIImage(systemName: "lock.fill"),
|
entryTask = Task { [weak self] in
|
||||||
style: .plain,
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
target: self,
|
do {
|
||||||
action: #selector(lockRequested)
|
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
||||||
|
try authentication.entryPage(path: path)
|
||||||
|
)
|
||||||
|
} 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(page):
|
||||||
|
self.page = page
|
||||||
|
title = page.title
|
||||||
|
contentUnavailableConfiguration = nil
|
||||||
|
installLockButton()
|
||||||
|
tableView.reloadData()
|
||||||
|
case let .failure(failure):
|
||||||
|
handle(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(at indexPath: IndexPath) -> MobileEntryField? {
|
||||||
|
guard
|
||||||
|
let page,
|
||||||
|
page.sections.indices.contains(indexPath.section),
|
||||||
|
page.sections[indexPath.section].fields.indices.contains(indexPath.row)
|
||||||
|
else { return nil }
|
||||||
|
return page.sections[indexPath.section].fields[indexPath.row]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealOrHide(_ field: MobileEntryField) {
|
||||||
|
if revealedValues.removeValue(forKey: field.id) != nil {
|
||||||
|
tableView.reloadData()
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) hidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performFieldAction(field) { authentication, path in
|
||||||
|
try authentication.revealEntryField(path: path, field: field.id)
|
||||||
|
} success: { [weak self] value in
|
||||||
|
self?.revealedValues[field.id] = value
|
||||||
|
self?.tableView.reloadData()
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) revealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy(_ field: MobileEntryField, in cell: MobileEntryFieldCell?) {
|
||||||
|
cell?.flashCopied()
|
||||||
|
performFieldAction(field) { authentication, path in
|
||||||
|
try authentication.copyEntryField(path: path, field: field.id)
|
||||||
|
} success: { [weak self] copy in
|
||||||
|
guard let self else { return }
|
||||||
|
UIPasteboard.general.string = copy.value
|
||||||
|
showFeedback("\(field.label) copied")
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) copied")
|
||||||
|
clipboardTask?.cancel()
|
||||||
|
clipboardTask = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if UIPasteboard.general.string == copy.value {
|
||||||
|
UIPasteboard.general.items = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MobileEntryPage, AuthenticationFailure>.success(
|
||||||
|
try authentication.replaceEntryField(
|
||||||
|
path: path,
|
||||||
|
field: field.id,
|
||||||
|
value: value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} 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(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 .failure(failure):
|
||||||
|
handle(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performFieldAction<Value: Sendable>(
|
||||||
|
_ field: MobileEntryField,
|
||||||
|
operation: @escaping @Sendable (MobileAuthentication, String) throws -> Value,
|
||||||
|
success: @escaping @MainActor (Value) -> Void
|
||||||
|
) {
|
||||||
|
guard let authentication else {
|
||||||
|
presentAuthenticationFailure(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let path = entry.path
|
||||||
|
Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
try authentication.touchUserActivity()
|
||||||
|
return Result<Value, AuthenticationFailure>.success(
|
||||||
|
try operation(authentication, path)
|
||||||
|
)
|
||||||
|
} 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(value): success(value)
|
||||||
|
case let .failure(failure): handle(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ failure: AuthenticationFailure) {
|
||||||
|
if failure.kind == .expired {
|
||||||
|
state = try? authentication?.state()
|
||||||
|
maskAndDiscardEntry()
|
||||||
|
showLocked()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
)
|
)
|
||||||
: nil
|
}
|
||||||
|
presentAuthenticationFailure(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maskAndDiscardEntry() {
|
||||||
|
entryTask?.cancel()
|
||||||
|
feedbackTask?.cancel()
|
||||||
|
page = nil
|
||||||
|
revealedValues.removeAll(keepingCapacity: false)
|
||||||
|
tableView.reloadData()
|
||||||
|
navigationItem.rightBarButtonItem = nil
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
title = entry.title
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showLocked() {
|
||||||
|
var configuration = UIContentUnavailableConfiguration.empty()
|
||||||
|
configuration.image = UIImage(systemName: "lock.fill")
|
||||||
|
configuration.text = "Locked Password"
|
||||||
|
configuration.secondaryText =
|
||||||
|
"Authenticate to decrypt this entry. Browsing remains available while locked."
|
||||||
|
configuration.button = .filled()
|
||||||
|
configuration.button.title = "Unlock"
|
||||||
|
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||||
|
self?.unlockRequested()
|
||||||
|
}
|
||||||
|
contentUnavailableConfiguration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showLoading(title: String) {
|
||||||
|
var configuration = UIContentUnavailableConfiguration.loading()
|
||||||
|
configuration.text = title
|
||||||
|
configuration.secondaryText = "Reading the structured entry from secure storage."
|
||||||
|
contentUnavailableConfiguration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFeedback(_ message: String) {
|
||||||
|
feedbackTask?.cancel()
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "checkmark.circle.fill"),
|
||||||
|
style: .plain,
|
||||||
|
target: nil,
|
||||||
|
action: nil
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityLabel = message
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
feedbackTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let self else { return }
|
||||||
|
if page != nil { installLockButton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installLockButton() {
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "lock.fill"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(lockRequested)
|
||||||
|
)
|
||||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
||||||
unlockButton.accessibilityHint = unlocked
|
}
|
||||||
? "Extends access after authentication."
|
}
|
||||||
: "Requests biometric authentication or the GPG key passphrase."
|
|
||||||
|
@MainActor
|
||||||
|
private final class MobileEntryFieldCell: UITableViewCell {
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let labelView = UILabel()
|
||||||
|
private let valueView = UITextView()
|
||||||
|
private let detailView = UILabel()
|
||||||
|
private let diagnosticView = UILabel()
|
||||||
|
private let revealButton = UIButton(type: .system)
|
||||||
|
private let editButton = UIButton(type: .system)
|
||||||
|
private var copyValue: ((MobileEntryFieldCell) -> Void)?
|
||||||
|
private var highlightTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private static let revealAction = UIAction.Identifier("reveal-entry-field")
|
||||||
|
private static let editAction = UIAction.Identifier("edit-entry-field")
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .default
|
||||||
|
iconView.tintColor = .secondaryLabel
|
||||||
|
iconView.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
labelView.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
labelView.textColor = .secondaryLabel
|
||||||
|
labelView.adjustsFontForContentSizeCategory = true
|
||||||
|
valueView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
valueView.adjustsFontForContentSizeCategory = true
|
||||||
|
valueView.backgroundColor = .clear
|
||||||
|
valueView.isEditable = false
|
||||||
|
valueView.isScrollEnabled = false
|
||||||
|
valueView.textContainerInset = .zero
|
||||||
|
valueView.textContainer.lineFragmentPadding = 0
|
||||||
|
let valueTap = UITapGestureRecognizer(target: self, action: #selector(valueTapped))
|
||||||
|
valueTap.cancelsTouchesInView = false
|
||||||
|
valueView.addGestureRecognizer(valueTap)
|
||||||
|
detailView.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
detailView.textColor = .secondaryLabel
|
||||||
|
detailView.adjustsFontForContentSizeCategory = true
|
||||||
|
detailView.numberOfLines = 0
|
||||||
|
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
diagnosticView.textColor = .systemOrange
|
||||||
|
diagnosticView.adjustsFontForContentSizeCategory = true
|
||||||
|
diagnosticView.numberOfLines = 0
|
||||||
|
revealButton.configuration = .plain()
|
||||||
|
editButton.configuration = .plain()
|
||||||
|
editButton.configuration?.image = UIImage(systemName: "pencil")
|
||||||
|
editButton.accessibilityLabel = "Edit field"
|
||||||
|
let labels = UIStackView(arrangedSubviews: [labelView, valueView, detailView, diagnosticView])
|
||||||
|
labels.axis = .vertical
|
||||||
|
labels.spacing = 3
|
||||||
|
labels.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
let actions = UIStackView(arrangedSubviews: [revealButton, editButton])
|
||||||
|
actions.distribution = .fillEqually
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
actions.widthAnchor.constraint(equalToConstant: 88),
|
||||||
|
actions.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||||
|
])
|
||||||
|
actions.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
actions.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
let row = UIStackView(arrangedSubviews: [iconView, labels, actions])
|
||||||
|
row.alignment = .top
|
||||||
|
row.spacing = 12
|
||||||
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(row)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
iconView.widthAnchor.constraint(equalToConstant: 24),
|
||||||
|
iconView.heightAnchor.constraint(equalToConstant: 24),
|
||||||
|
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: MobileEntryField,
|
||||||
|
revealedValue: String?,
|
||||||
|
copy: @escaping (MobileEntryFieldCell) -> Void,
|
||||||
|
reveal: @escaping () -> Void,
|
||||||
|
edit: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let value = revealedValue ?? field.value ?? field.maskedValue
|
||||||
|
iconView.image = UIImage(systemName: field.systemImage)
|
||||||
|
labelView.text = field.label
|
||||||
|
valueView.text = value
|
||||||
|
copyValue = copy
|
||||||
|
valueView.textColor = field.sensitive && revealedValue == nil ? .secondaryLabel : .label
|
||||||
|
valueView.isSelectable = field.selectable
|
||||||
|
detailView.text = field.detail
|
||||||
|
detailView.isHidden = field.detail == nil
|
||||||
|
diagnosticView.text = field.diagnostic
|
||||||
|
diagnosticView.isHidden = field.diagnostic == nil
|
||||||
|
revealButton.configuration?.image = UIImage(
|
||||||
|
systemName: revealedValue == nil ? "eye" : "eye.slash"
|
||||||
|
)
|
||||||
|
revealButton.accessibilityLabel = revealedValue == nil ? "Reveal field" : "Hide field"
|
||||||
|
revealButton.removeAction(identifiedBy: Self.revealAction, for: .touchUpInside)
|
||||||
|
revealButton.addAction(
|
||||||
|
UIAction(identifier: Self.revealAction) { _ in reveal() },
|
||||||
|
for: .touchUpInside
|
||||||
|
)
|
||||||
|
revealButton.alpha = field.sensitive ? 1 : 0
|
||||||
|
revealButton.isEnabled = field.sensitive
|
||||||
|
revealButton.accessibilityElementsHidden = !field.sensitive
|
||||||
|
editButton.removeAction(identifiedBy: Self.editAction, for: .touchUpInside)
|
||||||
|
editButton.addAction(
|
||||||
|
UIAction(identifier: Self.editAction) { _ in edit() },
|
||||||
|
for: .touchUpInside
|
||||||
|
)
|
||||||
|
editButton.alpha = field.editable ? 1 : 0
|
||||||
|
editButton.isEnabled = field.editable
|
||||||
|
editButton.accessibilityElementsHidden = !field.editable
|
||||||
|
accessibilityLabel = [field.label, value, field.detail, field.diagnostic]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
accessibilityHint = field.sensitive
|
||||||
|
? "Double tap the row to copy. Reveal and Edit buttons follow."
|
||||||
|
: "Double tap the row to copy. An Edit button follows."
|
||||||
|
}
|
||||||
|
|
||||||
|
func flashCopied() {
|
||||||
|
highlightTask?.cancel()
|
||||||
|
setHighlighted(true, animated: false)
|
||||||
|
highlightTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(350))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self?.setHighlighted(false, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
highlightTask?.cancel()
|
||||||
|
setHighlighted(false, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func valueTapped() {
|
||||||
|
copyValue?(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MobileEntryEditorViewController: UIViewController {
|
||||||
|
private let field: MobileEntryField
|
||||||
|
private let valueView = UITextView()
|
||||||
|
private let save: (String) -> Void
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .cancel,
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
||||||
|
)
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
valueView.becomeFirstResponder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ use ironstorage::{
|
|||||||
MobileAuthenticationError as StorageAuthenticationError,
|
MobileAuthenticationError as StorageAuthenticationError,
|
||||||
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||||
MobileAuthenticationState as StorageAuthenticationState,
|
MobileAuthenticationState as StorageAuthenticationState,
|
||||||
|
MobileEntryCopy as StorageEntryCopy,
|
||||||
|
},
|
||||||
|
mobile_entry::{
|
||||||
|
MobileEntryPage as StorageEntryPage, MobileEntrySectionKind as StorageEntrySectionKind,
|
||||||
},
|
},
|
||||||
mobile_home::{
|
mobile_home::{
|
||||||
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
||||||
@@ -532,6 +536,103 @@ pub struct MobileAuthenticationState {
|
|||||||
pub remaining_seconds: u64,
|
pub remaining_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileEntrySectionKind {
|
||||||
|
Password,
|
||||||
|
Details,
|
||||||
|
OneTimePassword,
|
||||||
|
Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageEntrySectionKind> for MobileEntrySectionKind {
|
||||||
|
fn from(kind: StorageEntrySectionKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
StorageEntrySectionKind::Password => Self::Password,
|
||||||
|
StorageEntrySectionKind::Details => Self::Details,
|
||||||
|
StorageEntrySectionKind::OneTimePassword => Self::OneTimePassword,
|
||||||
|
StorageEntrySectionKind::Notes => Self::Notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileEntryField {
|
||||||
|
pub id: u64,
|
||||||
|
pub label: String,
|
||||||
|
pub system_image: String,
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub masked_value: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
pub diagnostic: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub multiline: bool,
|
||||||
|
pub selectable: bool,
|
||||||
|
pub editable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileEntrySection {
|
||||||
|
pub kind: MobileEntrySectionKind,
|
||||||
|
pub title: String,
|
||||||
|
pub fields: Vec<MobileEntryField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileEntryPage {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub sections: Vec<MobileEntrySection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageEntryPage> for MobileEntryPage {
|
||||||
|
fn from(page: StorageEntryPage) -> Self {
|
||||||
|
Self {
|
||||||
|
id: page.id().to_owned(),
|
||||||
|
title: page.title().to_owned(),
|
||||||
|
sections: page
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.map(|section| MobileEntrySection {
|
||||||
|
kind: section.kind().into(),
|
||||||
|
title: section.title().to_owned(),
|
||||||
|
fields: section
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.map(|field| MobileEntryField {
|
||||||
|
id: field.id(),
|
||||||
|
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(),
|
||||||
|
detail: field.detail().map(str::to_owned),
|
||||||
|
diagnostic: field.diagnostic().map(str::to_owned),
|
||||||
|
sensitive: field.sensitive(),
|
||||||
|
multiline: field.multiline(),
|
||||||
|
selectable: field.selectable(),
|
||||||
|
editable: field.editable(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileEntryCopy {
|
||||||
|
pub value: String,
|
||||||
|
pub timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageEntryCopy> for MobileEntryCopy {
|
||||||
|
fn from(copy: StorageEntryCopy) -> Self {
|
||||||
|
Self {
|
||||||
|
value: copy.value().to_owned(),
|
||||||
|
timeout_seconds: copy.timeout_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<StorageAuthenticationState> for MobileAuthenticationState {
|
impl From<StorageAuthenticationState> for MobileAuthenticationState {
|
||||||
fn from(state: StorageAuthenticationState) -> Self {
|
fn from(state: StorageAuthenticationState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -616,6 +717,49 @@ impl MobileAuthentication {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn entry_page(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.entry_page(&path)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reveal_entry_field(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
field: u64,
|
||||||
|
) -> Result<String, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.reveal_entry_field(&path, field)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_entry_field(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
field: u64,
|
||||||
|
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.copy_entry_field(&path, field)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_entry_field(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
field: u64,
|
||||||
|
value: String,
|
||||||
|
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.replace_entry_field(&path, field, value)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> {
|
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
self.authentication.manual_lock().map_err(Into::into)
|
self.authentication.manual_lock().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ use crate::{
|
|||||||
pub struct EntryFieldId(u64);
|
pub struct EntryFieldId(u64);
|
||||||
|
|
||||||
impl EntryFieldId {
|
impl EntryFieldId {
|
||||||
|
pub fn from_value(value: u64) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn value(self) -> u64 {
|
pub fn value(self) -> u64 {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
@@ -629,7 +633,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
if index == 0 {
|
if index == 0 {
|
||||||
return EntryFieldMetadata {
|
return EntryFieldMetadata {
|
||||||
kind: EntryFieldKind::Password,
|
kind: EntryFieldKind::Password,
|
||||||
sensitivity: EntrySensitivity::Sensitive,
|
sensitivity: if line.is_empty() {
|
||||||
|
EntrySensitivity::Empty
|
||||||
|
} else {
|
||||||
|
EntrySensitivity::Sensitive
|
||||||
|
},
|
||||||
name: Some("password".to_owned()),
|
name: Some("password".to_owned()),
|
||||||
otp: None,
|
otp: None,
|
||||||
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
|
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
|
||||||
@@ -663,7 +671,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
let kind = semantic_field_kind(name);
|
let kind = semantic_field_kind(name);
|
||||||
return EntryFieldMetadata {
|
return EntryFieldMetadata {
|
||||||
kind,
|
kind,
|
||||||
sensitivity: field_sensitivity(name, kind),
|
sensitivity: EntrySensitivity::Ordinary,
|
||||||
name: Some(name.to_owned()),
|
name: Some(name.to_owned()),
|
||||||
otp: None,
|
otp: None,
|
||||||
diagnostic: std::str::from_utf8(&line[value_start..])
|
diagnostic: std::str::from_utf8(&line[value_start..])
|
||||||
@@ -674,7 +682,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
}
|
}
|
||||||
EntryFieldMetadata {
|
EntryFieldMetadata {
|
||||||
kind: EntryFieldKind::Note,
|
kind: EntryFieldKind::Note,
|
||||||
sensitivity: EntrySensitivity::Sensitive,
|
sensitivity: EntrySensitivity::Ordinary,
|
||||||
name: None,
|
name: None,
|
||||||
otp: None,
|
otp: None,
|
||||||
diagnostic: std::str::from_utf8(line)
|
diagnostic: std::str::from_utf8(line)
|
||||||
@@ -693,19 +701,6 @@ fn semantic_field_kind(name: &str) -> EntryFieldKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity {
|
|
||||||
if matches!(
|
|
||||||
kind,
|
|
||||||
EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url
|
|
||||||
) {
|
|
||||||
return EntrySensitivity::Ordinary;
|
|
||||||
}
|
|
||||||
match name.to_ascii_lowercase().as_str() {
|
|
||||||
"title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary,
|
|
||||||
_ => EntrySensitivity::Sensitive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_key(field: &EntryField) -> (u8, String) {
|
fn display_key(field: &EntryField) -> (u8, String) {
|
||||||
let metadata = field.metadata();
|
let metadata = field.metadata();
|
||||||
let name = metadata.name().unwrap_or_default().to_lowercase();
|
let name = metadata.name().unwrap_or_default().to_lowercase();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod git;
|
|||||||
pub mod kdbx;
|
pub mod kdbx;
|
||||||
pub mod mobile;
|
pub mod mobile;
|
||||||
pub mod mobile_authentication;
|
pub mod mobile_authentication;
|
||||||
|
pub mod mobile_entry;
|
||||||
pub mod mobile_home;
|
pub mod mobile_home;
|
||||||
pub mod mobile_onboarding;
|
pub mod mobile_onboarding;
|
||||||
pub mod mobile_passwords;
|
pub mod mobile_passwords;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use crate::{
|
|||||||
},
|
},
|
||||||
config::{Config, ConfigError},
|
config::{Config, ConfigError},
|
||||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
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},
|
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
|
||||||
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
||||||
};
|
};
|
||||||
@@ -109,6 +112,22 @@ pub struct MobileAuthenticationState {
|
|||||||
remaining_seconds: u64,
|
remaining_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileEntryCopy {
|
||||||
|
value: String,
|
||||||
|
timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileEntryCopy {
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timeout_seconds(&self) -> u64 {
|
||||||
|
self.timeout_seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MobileAuthenticationState {
|
impl MobileAuthenticationState {
|
||||||
pub fn unlocked(self) -> bool {
|
pub fn unlocked(self) -> bool {
|
||||||
self.unlocked
|
self.unlocked
|
||||||
@@ -328,6 +347,50 @@ impl MobileAuthentication {
|
|||||||
.map_err(MobileAuthenticationError::authentication)
|
.map_err(MobileAuthenticationError::authentication)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||||
|
let document = self.open_active_document(path)?;
|
||||||
|
Ok(MobileEntryPage::from_document(&document))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reveal_entry_field(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
field: u64,
|
||||||
|
) -> Result<String, MobileAuthenticationError> {
|
||||||
|
let document = self.open_active_document(path)?;
|
||||||
|
field_value(&document, field).map_err(value_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_entry_field(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
field: u64,
|
||||||
|
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||||
|
Ok(MobileEntryCopy {
|
||||||
|
value: self.reveal_entry_field(path, field)?,
|
||||||
|
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_entry_field(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
field: u64,
|
||||||
|
value: String,
|
||||||
|
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||||
|
let mut document = self.open_active_document(path)?;
|
||||||
|
document
|
||||||
|
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||||
|
.map_err(document_error)?;
|
||||||
|
let mut committer =
|
||||||
|
AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage())
|
||||||
|
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
|
||||||
|
EntryDocumentService::new(&self.repository, &self.keys)
|
||||||
|
.save_recoverable(&document, None, &mut committer)
|
||||||
|
.map_err(document_error)?;
|
||||||
|
Ok(MobileEntryPage::from_document(&document))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
|
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
|
||||||
self.session
|
self.session
|
||||||
.manual_lock()
|
.manual_lock()
|
||||||
@@ -383,6 +446,21 @@ impl MobileAuthentication {
|
|||||||
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
|
||||||
|
let (handle, key) = {
|
||||||
|
let status = self.status()?;
|
||||||
|
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||||
|
(active.handle.clone(), active.key.clone())
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.ensure_active()
|
||||||
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
|
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||||
|
EntryDocumentService::new(&self.repository, &self.keys)
|
||||||
|
.open(path, &mut provider)
|
||||||
|
.map_err(document_error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct KeyOnlyProvider<'a> {
|
struct KeyOnlyProvider<'a> {
|
||||||
@@ -431,3 +509,27 @@ fn key_error(error: CryptoError) -> MobileAuthenticationError {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn locked_error() -> MobileAuthenticationError {
|
||||||
|
MobileAuthenticationError::new(
|
||||||
|
MobileAuthenticationErrorKind::Expired,
|
||||||
|
"IronStorage Locked",
|
||||||
|
"Authenticate before using protected content.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_error(error: DocumentError) -> MobileAuthenticationError {
|
||||||
|
entry_detail("Password Entry Is Unavailable", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
||||||
|
entry_detail("Field Value Is Unavailable", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
|
||||||
|
MobileAuthenticationError::new(
|
||||||
|
MobileAuthenticationErrorKind::Entry,
|
||||||
|
title,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
305
crates/storage/src/mobile_entry.rs
Normal file
305
crates/storage/src/mobile_entry.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||||
|
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
use crate::document::{
|
||||||
|
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind,
|
||||||
|
EntrySensitivity,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileEntrySectionKind {
|
||||||
|
Password,
|
||||||
|
Details,
|
||||||
|
OneTimePassword,
|
||||||
|
Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileEntryField {
|
||||||
|
id: u64,
|
||||||
|
label: String,
|
||||||
|
system_image: String,
|
||||||
|
value: Option<String>,
|
||||||
|
masked_value: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
diagnostic: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
multiline: bool,
|
||||||
|
selectable: bool,
|
||||||
|
editable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileEntryField {
|
||||||
|
pub fn id(&self) -> u64 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
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 detail(&self) -> Option<&str> {
|
||||||
|
self.detail.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 selectable(&self) -> bool {
|
||||||
|
self.selectable
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn editable(&self) -> bool {
|
||||||
|
self.editable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileEntrySection {
|
||||||
|
kind: MobileEntrySectionKind,
|
||||||
|
title: String,
|
||||||
|
fields: Vec<MobileEntryField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileEntrySection {
|
||||||
|
pub fn kind(&self) -> MobileEntrySectionKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields(&self) -> &[MobileEntryField] {
|
||||||
|
&self.fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileEntryPage {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
sections: Vec<MobileEntrySection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileEntryPage {
|
||||||
|
pub fn from_document(document: &EntryDocument) -> Self {
|
||||||
|
let title = document
|
||||||
|
.path()
|
||||||
|
.as_path()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("Password")
|
||||||
|
.to_owned();
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
for (kind, section_title) in [
|
||||||
|
(MobileEntrySectionKind::Password, "Password"),
|
||||||
|
(MobileEntrySectionKind::Details, "Details"),
|
||||||
|
(MobileEntrySectionKind::OneTimePassword, "One-Time Password"),
|
||||||
|
(MobileEntrySectionKind::Notes, "Notes"),
|
||||||
|
] {
|
||||||
|
let fields = document
|
||||||
|
.display_fields()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|field| section_kind(field) == kind)
|
||||||
|
.map(mobile_field)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !fields.is_empty() {
|
||||||
|
sections.push(MobileEntrySection {
|
||||||
|
kind,
|
||||||
|
title: section_title.to_owned(),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
id: format!("entry:{}", document.path()),
|
||||||
|
title,
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sections(&self) -> &[MobileEntrySection] {
|
||||||
|
&self.sections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum MobileEntryValueError {
|
||||||
|
Document(DocumentError),
|
||||||
|
NonUtf8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileEntryValueError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Document(error) => error.fmt(formatter),
|
||||||
|
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileEntryValueError {}
|
||||||
|
|
||||||
|
impl From<DocumentError> for MobileEntryValueError {
|
||||||
|
fn from(error: DocumentError) -> Self {
|
||||||
|
Self::Document(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field_value(document: &EntryDocument, id: u64) -> Result<String, MobileEntryValueError> {
|
||||||
|
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
|
||||||
|
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_field(field: &EntryField) -> MobileEntryField {
|
||||||
|
let metadata = field.metadata();
|
||||||
|
let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
|
||||||
|
let value = if sensitive || metadata.sensitivity() == EntrySensitivity::Empty {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
String::from_utf8(field.value().to_vec()).ok()
|
||||||
|
};
|
||||||
|
MobileEntryField {
|
||||||
|
id: field.id().value(),
|
||||||
|
label: label(field),
|
||||||
|
system_image: system_image(metadata.kind()).to_owned(),
|
||||||
|
value,
|
||||||
|
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||||
|
"Empty"
|
||||||
|
} else if sensitive {
|
||||||
|
"Hidden"
|
||||||
|
} else {
|
||||||
|
"Unavailable"
|
||||||
|
}
|
||||||
|
.to_owned(),
|
||||||
|
detail: otp_detail(field),
|
||||||
|
diagnostic: metadata.diagnostic().map(diagnostic),
|
||||||
|
sensitive,
|
||||||
|
multiline: matches!(metadata.kind(), EntryFieldKind::Note)
|
||||||
|
|| field.value().contains(&b'\n'),
|
||||||
|
selectable: !sensitive && metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value),
|
||||||
|
editable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}) {
|
||||||
|
return MobileEntrySectionKind::Notes;
|
||||||
|
}
|
||||||
|
match field.metadata().kind() {
|
||||||
|
EntryFieldKind::Password => MobileEntrySectionKind::Password,
|
||||||
|
EntryFieldKind::OtpUri => MobileEntrySectionKind::OneTimePassword,
|
||||||
|
EntryFieldKind::Note => MobileEntrySectionKind::Notes,
|
||||||
|
EntryFieldKind::Username
|
||||||
|
| EntryFieldKind::Email
|
||||||
|
| EntryFieldKind::Url
|
||||||
|
| EntryFieldKind::Field => MobileEntrySectionKind::Details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(field: &EntryField) -> String {
|
||||||
|
if field.metadata().kind() == EntryFieldKind::Password {
|
||||||
|
return "Password".to_owned();
|
||||||
|
}
|
||||||
|
if field.metadata().kind() == EntryFieldKind::OtpUri {
|
||||||
|
return "OTP URI".to_owned();
|
||||||
|
}
|
||||||
|
field
|
||||||
|
.metadata()
|
||||||
|
.name()
|
||||||
|
.map(display_name)
|
||||||
|
.unwrap_or_else(|| match field.metadata().kind() {
|
||||||
|
EntryFieldKind::Username => "Username".to_owned(),
|
||||||
|
EntryFieldKind::Email => "Email".to_owned(),
|
||||||
|
EntryFieldKind::Url => "Website".to_owned(),
|
||||||
|
EntryFieldKind::Field => "Field".to_owned(),
|
||||||
|
EntryFieldKind::Note => "Notes".to_owned(),
|
||||||
|
EntryFieldKind::Password | EntryFieldKind::OtpUri => unreachable!(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_name(name: &str) -> String {
|
||||||
|
let mut characters = name.chars();
|
||||||
|
match characters.next() {
|
||||||
|
Some(first) => first.to_uppercase().chain(characters).collect(),
|
||||||
|
None => "Field".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_image(kind: EntryFieldKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
EntryFieldKind::Password => "key.fill",
|
||||||
|
EntryFieldKind::Username => "person.fill",
|
||||||
|
EntryFieldKind::Email => "envelope.fill",
|
||||||
|
EntryFieldKind::Url => "globe",
|
||||||
|
EntryFieldKind::OtpUri => "timer",
|
||||||
|
EntryFieldKind::Field => "text.alignleft",
|
||||||
|
EntryFieldKind::Note => "note.text",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic(diagnostic: EntryFieldDiagnostic) -> String {
|
||||||
|
match diagnostic {
|
||||||
|
EntryFieldDiagnostic::MalformedOtpUri => {
|
||||||
|
"This OTP URI is malformed. Its original value is preserved.".to_owned()
|
||||||
|
}
|
||||||
|
EntryFieldDiagnostic::NonUtf8Value => {
|
||||||
|
"This field is not UTF-8 text. Its original bytes are preserved.".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn otp_detail(field: &EntryField) -> Option<String> {
|
||||||
|
let otp = field.metadata().otp()?;
|
||||||
|
let mut parts = vec![format!("{:?}", otp.kind()).to_uppercase()];
|
||||||
|
if let Some(issuer) = otp.issuer() {
|
||||||
|
parts.push(issuer.to_owned());
|
||||||
|
}
|
||||||
|
if !otp.account().is_empty() {
|
||||||
|
parts.push(otp.account().to_owned());
|
||||||
|
}
|
||||||
|
parts.push(format!("{:?}", otp.algorithm()).to_uppercase());
|
||||||
|
parts.push(format!("{} digits", otp.digits()));
|
||||||
|
if let Some(period) = otp.period() {
|
||||||
|
parts.push(format!("{period} seconds"));
|
||||||
|
}
|
||||||
|
if let Some(counter) = otp.counter() {
|
||||||
|
parts.push(format!("counter {counter}"));
|
||||||
|
}
|
||||||
|
Some(parts.join(" · "))
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use ironstorage::{
|
|||||||
document::{
|
document::{
|
||||||
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
|
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
|
||||||
},
|
},
|
||||||
|
mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value},
|
||||||
recipient::RecipientPolicyManager,
|
recipient::RecipientPolicyManager,
|
||||||
repository::{EntryPath, Repository, SecretBytes},
|
repository::{EntryPath, Repository, SecretBytes},
|
||||||
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
|
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
|
||||||
@@ -134,6 +135,15 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
document.fields()[6].metadata().sensitivity(),
|
document.fields()[6].metadata().sensitivity(),
|
||||||
EntrySensitivity::Sensitive
|
EntrySensitivity::Sensitive
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.filter(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive)
|
||||||
|
.map(|field| field.metadata().kind())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[EntryFieldKind::Password, EntryFieldKind::OtpUri]
|
||||||
|
);
|
||||||
let otp = document.fields()[6]
|
let otp = document.fields()[6]
|
||||||
.metadata()
|
.metadata()
|
||||||
.otp()
|
.otp()
|
||||||
@@ -147,11 +157,123 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
assert_eq!(otp.counter(), None);
|
assert_eq!(otp.counter(), None);
|
||||||
let copied = document.copy_field_value(document.fields()[4].id())?;
|
let copied = document.copy_field_value(document.fields()[4].id())?;
|
||||||
assert_eq!(copied.expose(), b"one");
|
assert_eq!(copied.expose(), b"one");
|
||||||
|
let mobile = MobileEntryPage::from_document(&document);
|
||||||
|
let mobile_fields = mobile
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| section.fields())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(mobile.title(), "complex");
|
||||||
|
assert_eq!(mobile_fields.len(), document.fields().len());
|
||||||
|
assert_eq!(mobile_fields[0].label(), "Password");
|
||||||
|
assert!(mobile_fields[0].sensitive());
|
||||||
|
assert_eq!(mobile_fields[0].value(), None);
|
||||||
|
assert_eq!(mobile_fields[1].value(), Some("alice"));
|
||||||
|
let mobile_custom = mobile_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|field| field.label() == "Custom")
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(mobile_custom.len(), 2);
|
||||||
|
assert_ne!(mobile_custom[0].id(), mobile_custom[1].id());
|
||||||
|
assert_eq!(mobile_custom[0].value(), Some("one"));
|
||||||
|
assert_eq!(mobile_custom[1].value(), Some(""));
|
||||||
|
let mobile_otp = mobile_fields
|
||||||
|
.iter()
|
||||||
|
.find(|field| field.system_image() == "timer")
|
||||||
|
.expect("OTP field");
|
||||||
|
assert!(mobile_otp.sensitive());
|
||||||
|
assert_eq!(mobile_otp.value(), None);
|
||||||
|
assert!(mobile_otp.detail().is_some_and(|detail| {
|
||||||
|
detail.contains("TOTP") && detail.contains("Example") && detail.contains("alice")
|
||||||
|
}));
|
||||||
|
let mobile_note = mobile_fields
|
||||||
|
.iter()
|
||||||
|
.find(|field| field.system_image() == "note.text")
|
||||||
|
.expect("multiline note");
|
||||||
|
assert!(!mobile_note.sensitive());
|
||||||
|
assert!(mobile_note.multiline());
|
||||||
|
assert!(mobile_note.selectable());
|
||||||
|
assert_eq!(mobile_note.value(), Some("\r\nfirst note\r\nsecond note"));
|
||||||
|
assert_eq!(
|
||||||
|
field_value(&document, document.fields()[4].id().value())?,
|
||||||
|
"one"
|
||||||
|
);
|
||||||
assert!(!format!("{document:?}").contains("pässwörd"));
|
assert!(!format!("{document:?}").contains("pässwörd"));
|
||||||
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
|
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_projection_preserves_partially_understood_fields_without_exposing_them() -> 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/diagnostics",
|
||||||
|
b"password\notpauth://totp/broken\ncustom: \xff\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let document = EntryDocumentService::new(&repository, &keys)
|
||||||
|
.open("documents/diagnostics", &mut secrets)?;
|
||||||
|
let page = MobileEntryPage::from_document(&document);
|
||||||
|
let fields = page
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| section.fields())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(fields.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.filter(|field| field.diagnostic().is_some())
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(fields.iter().all(|field| field.value().is_none()));
|
||||||
|
assert!(field_value(&document, document.fields()[2].id().value()).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
document.serialize().expose(),
|
||||||
|
b"password\notpauth://totp/broken\ncustom: \xff\n"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> 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/empty-password",
|
||||||
|
b"\ncomments: ordinary note\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let document = EntryDocumentService::new(&repository, &keys)
|
||||||
|
.open("documents/empty-password", &mut secrets)?;
|
||||||
|
let password = document.password().expect("empty password field");
|
||||||
|
assert_eq!(password.metadata().sensitivity(), EntrySensitivity::Empty);
|
||||||
|
let page = MobileEntryPage::from_document(&document);
|
||||||
|
let password = page.sections()[0].fields().first().expect("password row");
|
||||||
|
assert_eq!(password.label(), "Password");
|
||||||
|
assert_eq!(password.value(), None);
|
||||||
|
assert_eq!(password.masked_value(), "Empty");
|
||||||
|
assert!(!password.sensitive());
|
||||||
|
assert_eq!(
|
||||||
|
document.serialize().expose(),
|
||||||
|
b"\ncomments: ordinary note\n"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
|
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
@@ -189,7 +311,7 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
|||||||
let repository = Repository::open(store.path())?;
|
let repository = Repository::open(store.path())?;
|
||||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
let mut secrets = FixtureSecrets::all(&fixture);
|
let mut secrets = FixtureSecrets::all(&fixture);
|
||||||
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\n";
|
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\ntags: travel, train\n";
|
||||||
write_plaintext(
|
write_plaintext(
|
||||||
&repository,
|
&repository,
|
||||||
&keys,
|
&keys,
|
||||||
@@ -200,8 +322,12 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
|||||||
let document =
|
let document =
|
||||||
EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
|
EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
|
||||||
assert_eq!(document.serialize().expose(), plaintext.as_bytes());
|
assert_eq!(document.serialize().expose(), plaintext.as_bytes());
|
||||||
assert_eq!(document.fields().len(), 4);
|
assert_eq!(document.fields().len(), 5);
|
||||||
assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
|
assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
|
||||||
|
assert_eq!(
|
||||||
|
document.fields()[1].metadata().sensitivity(),
|
||||||
|
EntrySensitivity::Ordinary
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document.fields()[1].value(),
|
document.fields()[1].value(),
|
||||||
"PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
|
"PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
|
||||||
@@ -212,6 +338,25 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
|||||||
Some("autotype_enabled")
|
Some("autotype_enabled")
|
||||||
);
|
);
|
||||||
assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
|
assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
|
||||||
|
assert_eq!(document.fields()[4].metadata().name(), Some("tags"));
|
||||||
|
assert_eq!(
|
||||||
|
document.fields()[4].metadata().sensitivity(),
|
||||||
|
EntrySensitivity::Ordinary
|
||||||
|
);
|
||||||
|
let mobile = MobileEntryPage::from_document(&document);
|
||||||
|
let comments = mobile
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.find(|section| section.kind() == MobileEntrySectionKind::Notes)
|
||||||
|
.and_then(|section| section.fields().first())
|
||||||
|
.expect("multiline comments section");
|
||||||
|
assert_eq!(comments.label(), "Comments");
|
||||||
|
assert!(!comments.sensitive());
|
||||||
|
assert!(comments.multiline());
|
||||||
|
assert_eq!(
|
||||||
|
comments.value(),
|
||||||
|
std::str::from_utf8(document.fields()[1].value()).ok()
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
|
EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
|
||||||
Err(DocumentError::InvalidFieldName)
|
Err(DocumentError::InvalidFieldName)
|
||||||
@@ -228,6 +373,10 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
|||||||
assert_eq!(note.password().expect("password").value(), b"password");
|
assert_eq!(note.password().expect("password").value(), b"password");
|
||||||
assert_eq!(note.fields().len(), 2);
|
assert_eq!(note.fields().len(), 2);
|
||||||
assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
|
assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
|
||||||
|
assert_eq!(
|
||||||
|
note.fields()[1].metadata().sensitivity(),
|
||||||
|
EntrySensitivity::Ordinary
|
||||||
|
);
|
||||||
|
|
||||||
let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
|
let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
|
||||||
write_plaintext(
|
write_plaintext(
|
||||||
|
|||||||
Reference in New Issue
Block a user