Implement iPhone password swipe actions
This commit is contained in:
@@ -625,6 +625,10 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func manualLock() throws
|
func manualLock() throws
|
||||||
|
|
||||||
|
func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome
|
||||||
|
|
||||||
|
func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan
|
||||||
|
|
||||||
func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage
|
func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage
|
||||||
|
|
||||||
func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage
|
func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage
|
||||||
@@ -821,6 +825,27 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func performEntryMutation(request: MobileMutationRequest)throws -> MobileMutationOutcome {
|
||||||
|
return try FfiConverterTypeMobileMutationOutcome_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterTypeMobileMutationRequest_lower(request),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
open func prepareEntryMutation(path: String, action: MobileMutationAction)throws -> MobileMutationPlan {
|
||||||
|
return try FfiConverterTypeMobileMutationPlan_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),
|
||||||
|
FfiConverterTypeMobileMutationAction_lower(action),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage {
|
open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage {
|
||||||
return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -2908,6 +2933,282 @@ public func FfiConverterTypeMobileKeyTransferProgress_lower(_ value: MobileKeyTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileMutationDestination: Equatable, Hashable {
|
||||||
|
public var path: String
|
||||||
|
public var title: String
|
||||||
|
public var detail: String
|
||||||
|
public var requiresOverwrite: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(path: String, title: String, detail: String, requiresOverwrite: Bool) {
|
||||||
|
self.path = path
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
self.requiresOverwrite = requiresOverwrite
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileMutationDestination: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileMutationDestination: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationDestination {
|
||||||
|
return
|
||||||
|
try MobileMutationDestination(
|
||||||
|
path: FfiConverterString.read(from: &buf),
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
detail: FfiConverterString.read(from: &buf),
|
||||||
|
requiresOverwrite: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileMutationDestination, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterString.write(value.path, into: &buf)
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterString.write(value.detail, into: &buf)
|
||||||
|
FfiConverterBool.write(value.requiresOverwrite, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationDestination_lift(_ buf: RustBuffer) throws -> MobileMutationDestination {
|
||||||
|
return try FfiConverterTypeMobileMutationDestination.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationDestination_lower(_ value: MobileMutationDestination) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileMutationDestination.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileMutationOutcome: Equatable, Hashable {
|
||||||
|
public var action: MobileMutationAction
|
||||||
|
public var source: String
|
||||||
|
public var destination: String?
|
||||||
|
public var title: String
|
||||||
|
public var detail: String
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(action: MobileMutationAction, source: String, destination: String?, title: String, detail: String) {
|
||||||
|
self.action = action
|
||||||
|
self.source = source
|
||||||
|
self.destination = destination
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileMutationOutcome: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileMutationOutcome: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationOutcome {
|
||||||
|
return
|
||||||
|
try MobileMutationOutcome(
|
||||||
|
action: FfiConverterTypeMobileMutationAction.read(from: &buf),
|
||||||
|
source: FfiConverterString.read(from: &buf),
|
||||||
|
destination: FfiConverterOptionString.read(from: &buf),
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
detail: FfiConverterString.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileMutationOutcome, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterTypeMobileMutationAction.write(value.action, into: &buf)
|
||||||
|
FfiConverterString.write(value.source, into: &buf)
|
||||||
|
FfiConverterOptionString.write(value.destination, into: &buf)
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterString.write(value.detail, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationOutcome_lift(_ buf: RustBuffer) throws -> MobileMutationOutcome {
|
||||||
|
return try FfiConverterTypeMobileMutationOutcome.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationOutcome_lower(_ value: MobileMutationOutcome) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileMutationOutcome.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileMutationPlan: Equatable, Hashable {
|
||||||
|
public var action: MobileMutationAction
|
||||||
|
public var source: String
|
||||||
|
public var sourceTitle: String
|
||||||
|
public var revision: String
|
||||||
|
public var destinations: [MobileMutationDestination]
|
||||||
|
public var hasOpenEditor: Bool
|
||||||
|
public var hasDirtyEditor: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(action: MobileMutationAction, source: String, sourceTitle: String, revision: String, destinations: [MobileMutationDestination], hasOpenEditor: Bool, hasDirtyEditor: Bool) {
|
||||||
|
self.action = action
|
||||||
|
self.source = source
|
||||||
|
self.sourceTitle = sourceTitle
|
||||||
|
self.revision = revision
|
||||||
|
self.destinations = destinations
|
||||||
|
self.hasOpenEditor = hasOpenEditor
|
||||||
|
self.hasDirtyEditor = hasDirtyEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileMutationPlan: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileMutationPlan: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationPlan {
|
||||||
|
return
|
||||||
|
try MobileMutationPlan(
|
||||||
|
action: FfiConverterTypeMobileMutationAction.read(from: &buf),
|
||||||
|
source: FfiConverterString.read(from: &buf),
|
||||||
|
sourceTitle: FfiConverterString.read(from: &buf),
|
||||||
|
revision: FfiConverterString.read(from: &buf),
|
||||||
|
destinations: FfiConverterSequenceTypeMobileMutationDestination.read(from: &buf),
|
||||||
|
hasOpenEditor: FfiConverterBool.read(from: &buf),
|
||||||
|
hasDirtyEditor: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileMutationPlan, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterTypeMobileMutationAction.write(value.action, into: &buf)
|
||||||
|
FfiConverterString.write(value.source, into: &buf)
|
||||||
|
FfiConverterString.write(value.sourceTitle, into: &buf)
|
||||||
|
FfiConverterString.write(value.revision, into: &buf)
|
||||||
|
FfiConverterSequenceTypeMobileMutationDestination.write(value.destinations, into: &buf)
|
||||||
|
FfiConverterBool.write(value.hasOpenEditor, into: &buf)
|
||||||
|
FfiConverterBool.write(value.hasDirtyEditor, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationPlan_lift(_ buf: RustBuffer) throws -> MobileMutationPlan {
|
||||||
|
return try FfiConverterTypeMobileMutationPlan.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationPlan_lower(_ value: MobileMutationPlan) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileMutationPlan.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileMutationRequest: Equatable, Hashable {
|
||||||
|
public var action: MobileMutationAction
|
||||||
|
public var source: String
|
||||||
|
public var revision: String
|
||||||
|
public var destination: String?
|
||||||
|
public var confirmed: Bool
|
||||||
|
public var overwrite: Bool
|
||||||
|
public var discardEditor: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(action: MobileMutationAction, source: String, revision: String, destination: String?, confirmed: Bool, overwrite: Bool, discardEditor: Bool) {
|
||||||
|
self.action = action
|
||||||
|
self.source = source
|
||||||
|
self.revision = revision
|
||||||
|
self.destination = destination
|
||||||
|
self.confirmed = confirmed
|
||||||
|
self.overwrite = overwrite
|
||||||
|
self.discardEditor = discardEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileMutationRequest: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileMutationRequest: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationRequest {
|
||||||
|
return
|
||||||
|
try MobileMutationRequest(
|
||||||
|
action: FfiConverterTypeMobileMutationAction.read(from: &buf),
|
||||||
|
source: FfiConverterString.read(from: &buf),
|
||||||
|
revision: FfiConverterString.read(from: &buf),
|
||||||
|
destination: FfiConverterOptionString.read(from: &buf),
|
||||||
|
confirmed: FfiConverterBool.read(from: &buf),
|
||||||
|
overwrite: FfiConverterBool.read(from: &buf),
|
||||||
|
discardEditor: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileMutationRequest, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterTypeMobileMutationAction.write(value.action, into: &buf)
|
||||||
|
FfiConverterString.write(value.source, into: &buf)
|
||||||
|
FfiConverterString.write(value.revision, into: &buf)
|
||||||
|
FfiConverterOptionString.write(value.destination, into: &buf)
|
||||||
|
FfiConverterBool.write(value.confirmed, into: &buf)
|
||||||
|
FfiConverterBool.write(value.overwrite, into: &buf)
|
||||||
|
FfiConverterBool.write(value.discardEditor, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationRequest_lift(_ buf: RustBuffer) throws -> MobileMutationRequest {
|
||||||
|
return try FfiConverterTypeMobileMutationRequest.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationRequest_lower(_ value: MobileMutationRequest) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileMutationRequest.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct MobileOnboardingDiscovery: Equatable, Hashable {
|
public struct MobileOnboardingDiscovery: Equatable, Hashable {
|
||||||
public var branches: [String]
|
public var branches: [String]
|
||||||
public var selectedBranch: UInt32
|
public var selectedBranch: UInt32
|
||||||
@@ -4611,6 +4912,79 @@ public func FfiConverterTypeMobileKeyTransferKind_lower(_ value: MobileKeyTransf
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public enum MobileMutationAction: Equatable, Hashable {
|
||||||
|
|
||||||
|
case move
|
||||||
|
case copy
|
||||||
|
case delete
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileMutationAction: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileMutationAction: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = MobileMutationAction
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationAction {
|
||||||
|
let variant: Int32 = try readInt(&buf)
|
||||||
|
switch variant {
|
||||||
|
|
||||||
|
case 1: return .move
|
||||||
|
|
||||||
|
case 2: return .copy
|
||||||
|
|
||||||
|
case 3: return .delete
|
||||||
|
|
||||||
|
default: throw UniffiInternalError.unexpectedEnumCase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileMutationAction, into buf: inout [UInt8]) {
|
||||||
|
switch value {
|
||||||
|
|
||||||
|
|
||||||
|
case .move:
|
||||||
|
writeInt(&buf, Int32(1))
|
||||||
|
|
||||||
|
|
||||||
|
case .copy:
|
||||||
|
writeInt(&buf, Int32(2))
|
||||||
|
|
||||||
|
|
||||||
|
case .delete:
|
||||||
|
writeInt(&buf, Int32(3))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationAction_lift(_ buf: RustBuffer) throws -> MobileMutationAction {
|
||||||
|
return try FfiConverterTypeMobileMutationAction.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileMutationAction_lower(_ value: MobileMutationAction) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileMutationAction.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public enum MobileOnboardingErrorKind: Equatable, Hashable {
|
public enum MobileOnboardingErrorKind: Equatable, Hashable {
|
||||||
|
|
||||||
case invalidInput
|
case invalidInput
|
||||||
@@ -5807,6 +6181,31 @@ fileprivate struct FfiConverterSequenceTypeMobileKeyTransferKey: FfiConverterRus
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceTypeMobileMutationDestination: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [MobileMutationDestination]
|
||||||
|
|
||||||
|
public static func write(_ value: [MobileMutationDestination], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterTypeMobileMutationDestination.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileMutationDestination] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [MobileMutationDestination]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterTypeMobileMutationDestination.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -6048,6 +6447,12 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation() != 7053) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entr
|
|||||||
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_PERFORM_ENTRY_MUTATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status
|
||||||
@@ -943,6 +953,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_
|
|||||||
#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_PERFORM_ENTRY_MUTATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||||
|
|||||||
@@ -2309,6 +2309,483 @@ private extension UInt64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class PasswordMutationCoordinator {
|
||||||
|
private weak var presenter: UIViewController?
|
||||||
|
private let authentication: MobileAuthentication?
|
||||||
|
private var operationTask: Task<Void, Never>?
|
||||||
|
private var working = false
|
||||||
|
private var contextualCompletion: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
init(presenter: UIViewController, authentication: MobileAuthentication?) {
|
||||||
|
self.presenter = presenter
|
||||||
|
self.authentication = authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
operationTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessoryView(for row: MobilePasswordRow) -> UIView {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
|
||||||
|
button.configuration = .plain()
|
||||||
|
button.configuration?.image = UIImage(systemName: "ellipsis.circle")
|
||||||
|
button.configuration?.buttonSize = .medium
|
||||||
|
button.menu = menu(for: row)
|
||||||
|
button.showsMenuAsPrimaryAction = true
|
||||||
|
button.accessibilityLabel = "Actions for \(row.title)"
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessibilityActions(for row: MobilePasswordRow) -> [UIAccessibilityCustomAction] {
|
||||||
|
[
|
||||||
|
accessibilityAction("Move", image: "folder", action: .move, row: row),
|
||||||
|
accessibilityAction("Copy", image: "doc.on.doc", action: .copy, row: row),
|
||||||
|
accessibilityAction("Delete", image: "trash", action: .delete, row: row),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func swipeConfiguration(for row: MobilePasswordRow) -> UISwipeActionsConfiguration {
|
||||||
|
let delete = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.begin(.delete, row: row, completion: completion)
|
||||||
|
}
|
||||||
|
delete.image = UIImage(systemName: "trash")
|
||||||
|
|
||||||
|
let copy = UIContextualAction(style: .normal, title: "Copy") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.begin(.copy, row: row, completion: completion)
|
||||||
|
}
|
||||||
|
copy.image = UIImage(systemName: "doc.on.doc")
|
||||||
|
copy.backgroundColor = .systemBlue
|
||||||
|
|
||||||
|
let move = UIContextualAction(style: .normal, title: "Move") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.begin(.move, row: row, completion: completion)
|
||||||
|
}
|
||||||
|
move.image = UIImage(systemName: "folder")
|
||||||
|
move.backgroundColor = .systemOrange
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [delete, copy, move])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func menu(for row: MobilePasswordRow) -> UIMenu {
|
||||||
|
UIMenu(children: [
|
||||||
|
UIAction(title: "Move", image: UIImage(systemName: "folder")) { [weak self] _ in
|
||||||
|
self?.begin(.move, row: row)
|
||||||
|
},
|
||||||
|
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
|
||||||
|
self?.begin(.copy, row: row)
|
||||||
|
},
|
||||||
|
UIAction(
|
||||||
|
title: "Delete",
|
||||||
|
image: UIImage(systemName: "trash"),
|
||||||
|
attributes: .destructive
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.begin(.delete, row: row)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accessibilityAction(
|
||||||
|
_ title: String,
|
||||||
|
image: String,
|
||||||
|
action: MobileMutationAction,
|
||||||
|
row: MobilePasswordRow
|
||||||
|
) -> UIAccessibilityCustomAction {
|
||||||
|
UIAccessibilityCustomAction(
|
||||||
|
name: title,
|
||||||
|
image: UIImage(systemName: image)
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.begin(action, row: row)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func begin(
|
||||||
|
_ action: MobileMutationAction,
|
||||||
|
row: MobilePasswordRow,
|
||||||
|
completion: ((Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard !working else {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let authentication else {
|
||||||
|
completion?(false)
|
||||||
|
presenter?.presentAuthenticationFailure(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
working = true
|
||||||
|
contextualCompletion = completion
|
||||||
|
operationTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<MobileMutationPlan, AuthenticationFailure>.success(
|
||||||
|
try authentication.prepareEntryMutation(path: row.path, action: action)
|
||||||
|
)
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
return .failure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
operationTask = nil
|
||||||
|
switch result {
|
||||||
|
case let .success(plan):
|
||||||
|
present(plan)
|
||||||
|
case let .failure(failure):
|
||||||
|
fail(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func present(_ plan: MobileMutationPlan) {
|
||||||
|
switch plan.action {
|
||||||
|
case .delete:
|
||||||
|
confirmDelete(plan)
|
||||||
|
case .move, .copy:
|
||||||
|
guard !plan.destinations.isEmpty else {
|
||||||
|
fail(AuthenticationFailure(
|
||||||
|
kind: .entry,
|
||||||
|
title: "No Destination Available",
|
||||||
|
detail: "Create another password folder before moving or copying this entry."
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let destinations = PasswordDestinationViewController(
|
||||||
|
plan: plan,
|
||||||
|
selected: { [weak self] destination in
|
||||||
|
self?.presenter?.dismiss(animated: true) {
|
||||||
|
self?.destinationSelected(destination, plan: plan)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelled: { [weak self] in
|
||||||
|
self?.presenter?.dismiss(animated: true) { self?.finish(false) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let navigation = UINavigationController(rootViewController: destinations)
|
||||||
|
navigation.modalPresentationStyle = .formSheet
|
||||||
|
presenter?.present(navigation, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destinationSelected(
|
||||||
|
_ destination: MobileMutationDestination,
|
||||||
|
plan: MobileMutationPlan
|
||||||
|
) {
|
||||||
|
if destination.requiresOverwrite {
|
||||||
|
let verb = plan.action == .move ? "Move" : "Copy"
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Replace Existing Password?",
|
||||||
|
message: "\(destination.title) already contains “\(plan.sourceTitle)”. \(verb) and replace it?",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
|
||||||
|
[weak self] _ in self?.finish(false)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "Replace", style: .destructive) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.confirmEditorIfNeeded(plan, destination: destination.path, overwrite: true)
|
||||||
|
})
|
||||||
|
presenter?.present(alert, animated: true)
|
||||||
|
} else {
|
||||||
|
confirmEditorIfNeeded(plan, destination: destination.path, overwrite: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(_ plan: MobileMutationPlan) {
|
||||||
|
let suffix = plan.hasDirtyEditor
|
||||||
|
? " Unsaved changes in its open editor will also be discarded."
|
||||||
|
: " This can’t be undone."
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete “\(plan.sourceTitle)”?",
|
||||||
|
message: "The encrypted password entry will be permanently deleted.\(suffix)",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
|
||||||
|
[weak self] _ in self?.finish(false)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.perform(
|
||||||
|
plan,
|
||||||
|
destination: nil,
|
||||||
|
overwrite: false,
|
||||||
|
discardEditor: plan.hasOpenEditor,
|
||||||
|
mayAuthenticate: true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
presenter?.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmEditorIfNeeded(
|
||||||
|
_ plan: MobileMutationPlan,
|
||||||
|
destination: String,
|
||||||
|
overwrite: Bool
|
||||||
|
) {
|
||||||
|
guard plan.hasOpenEditor else {
|
||||||
|
perform(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: false,
|
||||||
|
mayAuthenticate: true
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let verb = plan.action == .move ? "Move" : "Copy"
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: plan.hasDirtyEditor ? "Discard Changes and \(verb)?" : "Close Editor and \(verb)?",
|
||||||
|
message: plan.hasDirtyEditor
|
||||||
|
? "Unsaved changes in the open editor for “\(plan.sourceTitle)” will be discarded."
|
||||||
|
: "The open editor for “\(plan.sourceTitle)” must close before this action.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
|
||||||
|
[weak self] _ in self?.finish(false)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(
|
||||||
|
title: plan.hasDirtyEditor ? "Discard and \(verb)" : "Close and \(verb)",
|
||||||
|
style: plan.hasDirtyEditor ? .destructive : .default
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.perform(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: true,
|
||||||
|
mayAuthenticate: true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
presenter?.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform(
|
||||||
|
_ plan: MobileMutationPlan,
|
||||||
|
destination: String?,
|
||||||
|
overwrite: Bool,
|
||||||
|
discardEditor: Bool,
|
||||||
|
mayAuthenticate: Bool
|
||||||
|
) {
|
||||||
|
guard let authentication else {
|
||||||
|
fail(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = MobileMutationRequest(
|
||||||
|
action: plan.action,
|
||||||
|
source: plan.source,
|
||||||
|
revision: plan.revision,
|
||||||
|
destination: destination,
|
||||||
|
confirmed: plan.action == .delete,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: discardEditor
|
||||||
|
)
|
||||||
|
operationTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<MobileMutationOutcome, AuthenticationFailure>.success(
|
||||||
|
try authentication.performEntryMutation(request: request)
|
||||||
|
)
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
return .failure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
operationTask = nil
|
||||||
|
switch result {
|
||||||
|
case let .success(outcome):
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: outcome.detail)
|
||||||
|
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||||
|
finish(true)
|
||||||
|
case let .failure(failure)
|
||||||
|
where mayAuthenticate && failure.kind == .expired:
|
||||||
|
unlockAndRetry(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: discardEditor,
|
||||||
|
passphrase: nil
|
||||||
|
)
|
||||||
|
case let .failure(failure):
|
||||||
|
fail(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unlockAndRetry(
|
||||||
|
_ plan: MobileMutationPlan,
|
||||||
|
destination: String?,
|
||||||
|
overwrite: Bool,
|
||||||
|
discardEditor: Bool,
|
||||||
|
passphrase: String?
|
||||||
|
) {
|
||||||
|
guard let authentication else {
|
||||||
|
fail(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operationTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
_ = try authentication.unlockEntry(path: plan.source, passphrase: passphrase)
|
||||||
|
return Result<Void, AuthenticationFailure>.success(())
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
return .failure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
operationTask = nil
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
perform(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: discardEditor,
|
||||||
|
mayAuthenticate: false
|
||||||
|
)
|
||||||
|
case let .failure(failure)
|
||||||
|
where passphrase == nil
|
||||||
|
&& (failure.kind == .passphraseRequired
|
||||||
|
|| failure.kind == .biometryUnavailable):
|
||||||
|
promptForPassphrase(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: discardEditor,
|
||||||
|
message: failure.detail
|
||||||
|
)
|
||||||
|
case let .failure(failure):
|
||||||
|
fail(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptForPassphrase(
|
||||||
|
_ plan: MobileMutationPlan,
|
||||||
|
destination: String?,
|
||||||
|
overwrite: Bool,
|
||||||
|
discardEditor: Bool,
|
||||||
|
message: String
|
||||||
|
) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "GPG Key Passphrase",
|
||||||
|
message: message,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addTextField { field in
|
||||||
|
field.isSecureTextEntry = true
|
||||||
|
field.textContentType = .password
|
||||||
|
field.placeholder = "Passphrase"
|
||||||
|
field.returnKeyType = .go
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
|
||||||
|
[weak self] _ in self?.finish(false)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "Unlock", style: .default) {
|
||||||
|
[weak self, weak alert] _ in
|
||||||
|
guard let value = alert?.textFields?.first?.text, !value.isEmpty else {
|
||||||
|
self?.finish(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alert?.textFields?.first?.text = nil
|
||||||
|
self?.unlockAndRetry(
|
||||||
|
plan,
|
||||||
|
destination: destination,
|
||||||
|
overwrite: overwrite,
|
||||||
|
discardEditor: discardEditor,
|
||||||
|
passphrase: value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
presenter?.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ failure: AuthenticationFailure) {
|
||||||
|
presenter?.presentAuthenticationFailure(failure)
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ applied: Bool) {
|
||||||
|
contextualCompletion?(applied)
|
||||||
|
contextualCompletion = nil
|
||||||
|
working = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class PasswordDestinationViewController: UITableViewController {
|
||||||
|
private let plan: MobileMutationPlan
|
||||||
|
private let selected: (MobileMutationDestination) -> Void
|
||||||
|
private let cancelled: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
plan: MobileMutationPlan,
|
||||||
|
selected: @escaping (MobileMutationDestination) -> Void,
|
||||||
|
cancelled: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.plan = plan
|
||||||
|
self.selected = selected
|
||||||
|
self.cancelled = cancelled
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = plan.action == .move ? "Move to Folder" : "Copy to Folder"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .cancel,
|
||||||
|
primaryAction: UIAction { [weak self] _ in self?.cancelled() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
plan.destinations.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
plan.sourceTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let destination = plan.destinations[indexPath.row]
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.image = UIImage(systemName: "folder")
|
||||||
|
content.text = destination.title
|
||||||
|
content.secondaryText = destination.requiresOverwrite
|
||||||
|
? "\(destination.detail) · Replaces existing password"
|
||||||
|
: destination.detail
|
||||||
|
content.secondaryTextProperties.numberOfLines = 2
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
cell.accessibilityHint = destination.requiresOverwrite
|
||||||
|
? "Requires confirmation before replacing the existing password."
|
||||||
|
: "Selects this destination folder."
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
selected(plan.destinations[indexPath.row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
|
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
|
||||||
UISearchResultsUpdating
|
UISearchResultsUpdating
|
||||||
@@ -2321,6 +2798,10 @@ private final class PasswordSearchViewController: UITableViewController, MobileT
|
|||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
private var shellTask: Task<Void, Never>?
|
private var shellTask: Task<Void, Never>?
|
||||||
private var generation = 0
|
private var generation = 0
|
||||||
|
private lazy var mutationCoordinator = PasswordMutationCoordinator(
|
||||||
|
presenter: self,
|
||||||
|
authentication: authentication
|
||||||
|
)
|
||||||
|
|
||||||
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
|
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
|
||||||
self.shellPage = shellPage
|
self.shellPage = shellPage
|
||||||
@@ -2395,12 +2876,22 @@ private final class PasswordSearchViewController: UITableViewController, MobileT
|
|||||||
content.textProperties.numberOfLines = 2
|
content.textProperties.numberOfLines = 2
|
||||||
content.secondaryTextProperties.numberOfLines = 2
|
content.secondaryTextProperties.numberOfLines = 2
|
||||||
cell.contentConfiguration = content
|
cell.contentConfiguration = content
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
|
||||||
|
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
|
||||||
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
|
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
|
||||||
cell.accessibilityHint = "Opens the locked password viewer."
|
cell.accessibilityHint = "Opens the locked password viewer. More actions follow."
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
guard let rows = searchPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
return mutationCoordinator.swipeConfiguration(for: row)
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
guard let row = searchPage?.rows[indexPath.row] else { return }
|
guard let row = searchPage?.rows[indexPath.row] else { return }
|
||||||
@@ -2534,6 +3025,10 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
|||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var createTask: Task<Void, Never>?
|
private var createTask: Task<Void, Never>?
|
||||||
private var loadGeneration = 0
|
private var loadGeneration = 0
|
||||||
|
private lazy var mutationCoordinator = PasswordMutationCoordinator(
|
||||||
|
presenter: self,
|
||||||
|
authentication: authentication
|
||||||
|
)
|
||||||
|
|
||||||
init(
|
init(
|
||||||
shellPage: MobilePage,
|
shellPage: MobilePage,
|
||||||
@@ -2626,14 +3121,29 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
|||||||
content.textProperties.numberOfLines = 2
|
content.textProperties.numberOfLines = 2
|
||||||
content.secondaryTextProperties.numberOfLines = 1
|
content.secondaryTextProperties.numberOfLines = 1
|
||||||
cell.contentConfiguration = content
|
cell.contentConfiguration = content
|
||||||
cell.accessoryType = .disclosureIndicator
|
if row.kind == .entry {
|
||||||
|
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
|
||||||
|
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
|
||||||
|
} else {
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
}
|
||||||
cell.accessibilityLabel = "\(row.title), \(row.detail)"
|
cell.accessibilityLabel = "\(row.title), \(row.detail)"
|
||||||
cell.accessibilityHint = row.kind == .directory
|
cell.accessibilityHint = row.kind == .directory
|
||||||
? "Opens this password folder."
|
? "Opens this password folder."
|
||||||
: "Opens the locked password viewer."
|
: "Opens the locked password viewer. More actions follow."
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
guard let rows = directoryPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
guard row.kind == .entry else { return nil }
|
||||||
|
return mutationCoordinator.swipeConfiguration(for: row)
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
||||||
let controller: UIViewController = switch row.kind {
|
let controller: UIViewController = switch row.kind {
|
||||||
@@ -4125,7 +4635,7 @@ private struct AuthenticationFailure: Error, Sendable {
|
|||||||
detail: "Finish password-store setup before unlocking entries."
|
detail: "Finish password-store setup before unlocking entries."
|
||||||
)
|
)
|
||||||
|
|
||||||
private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
|
fileprivate init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.title = title
|
self.title = title
|
||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ use ironstorage::{
|
|||||||
MobileKeyTransferKind as StorageKeyTransferKind,
|
MobileKeyTransferKind as StorageKeyTransferKind,
|
||||||
MobileKeyTransferProgress as StorageKeyTransferProgress,
|
MobileKeyTransferProgress as StorageKeyTransferProgress,
|
||||||
},
|
},
|
||||||
|
mobile_mutation::{
|
||||||
|
MobileMutationAction as StorageMutationAction,
|
||||||
|
MobileMutationOutcome as StorageMutationOutcome, MobileMutationPlan as StorageMutationPlan,
|
||||||
|
MobileMutationRequest as StorageMutationRequest,
|
||||||
|
},
|
||||||
mobile_onboarding::{
|
mobile_onboarding::{
|
||||||
self, MobileOnboardingError as StorageOnboardingError,
|
self, MobileOnboardingError as StorageOnboardingError,
|
||||||
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||||
@@ -560,6 +565,121 @@ pub struct MobileAuthenticationState {
|
|||||||
pub remaining_seconds: u64,
|
pub remaining_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileMutationAction {
|
||||||
|
Move,
|
||||||
|
Copy,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageMutationAction> for MobileMutationAction {
|
||||||
|
fn from(action: StorageMutationAction) -> Self {
|
||||||
|
match action {
|
||||||
|
StorageMutationAction::Move => Self::Move,
|
||||||
|
StorageMutationAction::Copy => Self::Copy,
|
||||||
|
StorageMutationAction::Delete => Self::Delete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MobileMutationAction> for StorageMutationAction {
|
||||||
|
fn from(action: MobileMutationAction) -> Self {
|
||||||
|
match action {
|
||||||
|
MobileMutationAction::Move => Self::Move,
|
||||||
|
MobileMutationAction::Copy => Self::Copy,
|
||||||
|
MobileMutationAction::Delete => Self::Delete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileMutationDestination {
|
||||||
|
pub path: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub requires_overwrite: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileMutationPlan {
|
||||||
|
pub action: MobileMutationAction,
|
||||||
|
pub source: String,
|
||||||
|
pub source_title: String,
|
||||||
|
pub revision: String,
|
||||||
|
pub destinations: Vec<MobileMutationDestination>,
|
||||||
|
pub has_open_editor: bool,
|
||||||
|
pub has_dirty_editor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageMutationPlan> for MobileMutationPlan {
|
||||||
|
fn from(plan: StorageMutationPlan) -> Self {
|
||||||
|
Self {
|
||||||
|
action: plan.action().into(),
|
||||||
|
source: plan.source().to_owned(),
|
||||||
|
source_title: plan.source_title().to_owned(),
|
||||||
|
revision: plan.revision().to_owned(),
|
||||||
|
destinations: plan
|
||||||
|
.destinations()
|
||||||
|
.iter()
|
||||||
|
.map(|destination| MobileMutationDestination {
|
||||||
|
path: destination.path().to_owned(),
|
||||||
|
title: destination.title().to_owned(),
|
||||||
|
detail: destination.detail().to_owned(),
|
||||||
|
requires_overwrite: destination.requires_overwrite(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
has_open_editor: plan.has_open_editor(),
|
||||||
|
has_dirty_editor: plan.has_dirty_editor(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileMutationRequest {
|
||||||
|
pub action: MobileMutationAction,
|
||||||
|
pub source: String,
|
||||||
|
pub revision: String,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub confirmed: bool,
|
||||||
|
pub overwrite: bool,
|
||||||
|
pub discard_editor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MobileMutationRequest> for StorageMutationRequest {
|
||||||
|
fn from(request: MobileMutationRequest) -> Self {
|
||||||
|
Self {
|
||||||
|
action: request.action.into(),
|
||||||
|
source: request.source,
|
||||||
|
revision: request.revision,
|
||||||
|
destination: request.destination,
|
||||||
|
confirmed: request.confirmed,
|
||||||
|
overwrite: request.overwrite,
|
||||||
|
discard_editor: request.discard_editor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileMutationOutcome {
|
||||||
|
pub action: MobileMutationAction,
|
||||||
|
pub source: String,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageMutationOutcome> for MobileMutationOutcome {
|
||||||
|
fn from(outcome: StorageMutationOutcome) -> Self {
|
||||||
|
Self {
|
||||||
|
action: outcome.action().into(),
|
||||||
|
source: outcome.source().to_owned(),
|
||||||
|
destination: outcome.destination().map(str::to_owned),
|
||||||
|
title: outcome.title().to_owned(),
|
||||||
|
detail: outcome.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileEntrySectionKind {
|
pub enum MobileEntrySectionKind {
|
||||||
Password,
|
Password,
|
||||||
@@ -1193,6 +1313,27 @@ impl MobileAuthentication {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prepare_entry_mutation(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
action: MobileMutationAction,
|
||||||
|
) -> Result<MobileMutationPlan, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.prepare_entry_mutation(&path, action.into())
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform_entry_mutation(
|
||||||
|
&self,
|
||||||
|
request: MobileMutationRequest,
|
||||||
|
) -> Result<MobileMutationOutcome, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.perform_entry_mutation(request.into())
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn replace_entry_field(
|
pub fn replace_entry_field(
|
||||||
&self,
|
&self,
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod mobile_authentication;
|
|||||||
pub mod mobile_entry;
|
pub mod mobile_entry;
|
||||||
pub mod mobile_home;
|
pub mod mobile_home;
|
||||||
pub mod mobile_key_transfer;
|
pub mod mobile_key_transfer;
|
||||||
|
pub mod mobile_mutation;
|
||||||
pub mod mobile_onboarding;
|
pub mod mobile_onboarding;
|
||||||
pub mod mobile_passwords;
|
pub mod mobile_passwords;
|
||||||
pub mod mobile_totp;
|
pub mod mobile_totp;
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ 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},
|
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||||
git::{AutomaticEntryCommitter, GitIdentity},
|
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||||
mobile_entry::{
|
mobile_entry::{
|
||||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||||
MobileEntryValueError, field_value,
|
MobileEntryValueError, field_value,
|
||||||
},
|
},
|
||||||
|
mobile_mutation::{
|
||||||
|
MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan,
|
||||||
|
MobileMutationRequest, MobileMutationService,
|
||||||
|
},
|
||||||
mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService},
|
mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService},
|
||||||
recipient::RecipientPolicyManager,
|
recipient::RecipientPolicyManager,
|
||||||
repository::{
|
repository::{
|
||||||
@@ -162,6 +166,7 @@ struct MobileAuthenticationStatus {
|
|||||||
active: Option<ActiveMobileLease>,
|
active: Option<ActiveMobileLease>,
|
||||||
next_editor_id: u64,
|
next_editor_id: u64,
|
||||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||||
|
mutation_active: bool,
|
||||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +201,7 @@ impl MobileAuthentication {
|
|||||||
active: None,
|
active: None,
|
||||||
next_editor_id: 0,
|
next_editor_id: 0,
|
||||||
editors: BTreeMap::new(),
|
editors: BTreeMap::new(),
|
||||||
|
mutation_active: false,
|
||||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||||
}),
|
}),
|
||||||
config,
|
config,
|
||||||
@@ -399,6 +405,66 @@ impl MobileAuthentication {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prepare_entry_mutation(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
action: MobileMutationAction,
|
||||||
|
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||||
|
let (has_open_editor, has_dirty_editor) = self.editor_state(path)?;
|
||||||
|
MobileMutationService::new(&self.repository)
|
||||||
|
.prepare(path, action, has_open_editor, has_dirty_editor)
|
||||||
|
.map_err(mutation_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform_entry_mutation(
|
||||||
|
&self,
|
||||||
|
request: MobileMutationRequest,
|
||||||
|
) -> Result<MobileMutationOutcome, MobileAuthenticationError> {
|
||||||
|
self.ensure_active()?;
|
||||||
|
self.reserve_entry_mutation()?;
|
||||||
|
let result = self.perform_reserved_entry_mutation(request);
|
||||||
|
self.release_entry_mutation();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn perform_reserved_entry_mutation(
|
||||||
|
&self,
|
||||||
|
request: MobileMutationRequest,
|
||||||
|
) -> Result<MobileMutationOutcome, 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())
|
||||||
|
};
|
||||||
|
let mut committer = AutomaticTreeCommitter::for_source(
|
||||||
|
&self.repository,
|
||||||
|
&request.source,
|
||||||
|
GitIdentity::ironstorage(),
|
||||||
|
)
|
||||||
|
.map_err(git_mutation_error)?;
|
||||||
|
let has_open_editor = self.editor_state(&request.source)?.0;
|
||||||
|
let editors = if has_open_editor && request.discard_editor {
|
||||||
|
self.take_entry_editors(&request.source)?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||||
|
let result = MobileMutationService::new(&self.repository).perform(
|
||||||
|
&request,
|
||||||
|
has_open_editor && editors.is_empty(),
|
||||||
|
&self.keys,
|
||||||
|
&mut provider,
|
||||||
|
&mut committer,
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(outcome) => Ok(outcome),
|
||||||
|
Err(error) => {
|
||||||
|
self.restore_entry_editors(editors)?;
|
||||||
|
Err(mutation_error(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unlock_totp(
|
pub fn unlock_totp(
|
||||||
&self,
|
&self,
|
||||||
passphrase: Option<SecretBytes>,
|
passphrase: Option<SecretBytes>,
|
||||||
@@ -766,6 +832,12 @@ impl MobileAuthentication {
|
|||||||
draft: MobileEntryDraft,
|
draft: MobileEntryDraft,
|
||||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||||
let mut status = self.status()?;
|
let mut status = self.status()?;
|
||||||
|
if status.mutation_active {
|
||||||
|
return Err(entry_detail(
|
||||||
|
"Password Action In Progress",
|
||||||
|
"wait for the current move, copy, or delete action to finish",
|
||||||
|
));
|
||||||
|
}
|
||||||
let id = status.next_editor_id;
|
let id = status.next_editor_id;
|
||||||
status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| {
|
status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| {
|
||||||
entry_detail(
|
entry_detail(
|
||||||
@@ -778,6 +850,66 @@ impl MobileAuthentication {
|
|||||||
Ok(MobileEntryEditorSession::new(id, page))
|
Ok(MobileEntryEditorSession::new(id, page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
|
||||||
|
let mut status = self.status()?;
|
||||||
|
// ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
|
||||||
|
if status.mutation_active {
|
||||||
|
return Err(MobileAuthenticationError::new(
|
||||||
|
MobileAuthenticationErrorKind::Conflict,
|
||||||
|
"Password Action In Progress",
|
||||||
|
"Wait for the current move, copy, or delete action to finish.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
status.mutation_active = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_entry_mutation(&self) {
|
||||||
|
if let Ok(mut status) = self.status.lock() {
|
||||||
|
status.mutation_active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> {
|
||||||
|
let status = self.status()?;
|
||||||
|
let mut matching = status
|
||||||
|
.editors
|
||||||
|
.values()
|
||||||
|
.filter(|draft| draft.document().path().to_string() == path);
|
||||||
|
let Some(first) = matching.next() else {
|
||||||
|
return Ok((false, false));
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
true,
|
||||||
|
first.document().is_modified() || matching.any(|draft| draft.document().is_modified()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_entry_editors(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Vec<(u64, MobileEntryDraft)>, MobileAuthenticationError> {
|
||||||
|
let mut status = self.status()?;
|
||||||
|
let ids = status
|
||||||
|
.editors
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, draft)| draft.document().path().to_string() == path)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(ids
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| status.editors.remove(&id).map(|draft| (id, draft)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_entry_editors(
|
||||||
|
&self,
|
||||||
|
editors: Vec<(u64, MobileEntryDraft)>,
|
||||||
|
) -> Result<(), MobileAuthenticationError> {
|
||||||
|
self.status()?.editors.extend(editors);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn restore_editor(
|
fn restore_editor(
|
||||||
&self,
|
&self,
|
||||||
editor: u64,
|
editor: u64,
|
||||||
@@ -870,6 +1002,38 @@ fn document_error(error: DocumentError) -> MobileAuthenticationError {
|
|||||||
MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string())
|
MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mutation_error(error: MobileMutationError) -> MobileAuthenticationError {
|
||||||
|
MobileAuthenticationError::new(
|
||||||
|
if error.is_conflict() {
|
||||||
|
MobileAuthenticationErrorKind::Conflict
|
||||||
|
} else {
|
||||||
|
MobileAuthenticationErrorKind::Entry
|
||||||
|
},
|
||||||
|
error.title(),
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_mutation_error(error: GitError) -> MobileAuthenticationError {
|
||||||
|
let conflict = matches!(
|
||||||
|
&error,
|
||||||
|
GitError::DirtyWorktree | GitError::MergeConflicts { .. }
|
||||||
|
);
|
||||||
|
MobileAuthenticationError::new(
|
||||||
|
if conflict {
|
||||||
|
MobileAuthenticationErrorKind::Conflict
|
||||||
|
} else {
|
||||||
|
MobileAuthenticationErrorKind::Entry
|
||||||
|
},
|
||||||
|
if conflict {
|
||||||
|
"Password Action Blocked by Git"
|
||||||
|
} else {
|
||||||
|
"Password Action Failed"
|
||||||
|
},
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
||||||
entry_detail("Field Value Is Unavailable", error)
|
entry_detail("Field Value Is Unavailable", error)
|
||||||
}
|
}
|
||||||
|
|||||||
567
crates/storage/src/mobile_mutation.rs
Normal file
567
crates/storage/src/mobile_mutation.rs
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
//! Storage-owned preparation and execution of native mobile entry mutations.
|
||||||
|
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
command::{CopyRequest, MoveRequest, RemoveRequest},
|
||||||
|
crypto::{KeyStore, SecretProvider},
|
||||||
|
mutation::{MutationError, TreeCommitter, TreeMutator},
|
||||||
|
read::hidden_path,
|
||||||
|
repository::{DirectoryPath, EntryPath, Repository, RepositoryError},
|
||||||
|
write::OverwriteDecision,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileMutationAction {
|
||||||
|
Move,
|
||||||
|
Copy,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileMutationDestination {
|
||||||
|
path: String,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
requires_overwrite: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileMutationDestination {
|
||||||
|
pub fn path(&self) -> &str {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requires_overwrite(&self) -> bool {
|
||||||
|
self.requires_overwrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileMutationPlan {
|
||||||
|
action: MobileMutationAction,
|
||||||
|
source: String,
|
||||||
|
source_title: String,
|
||||||
|
revision: String,
|
||||||
|
destinations: Vec<MobileMutationDestination>,
|
||||||
|
has_open_editor: bool,
|
||||||
|
has_dirty_editor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileMutationPlan {
|
||||||
|
pub fn action(&self) -> MobileMutationAction {
|
||||||
|
self.action
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source(&self) -> &str {
|
||||||
|
&self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source_title(&self) -> &str {
|
||||||
|
&self.source_title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revision(&self) -> &str {
|
||||||
|
&self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destinations(&self) -> &[MobileMutationDestination] {
|
||||||
|
&self.destinations
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_open_editor(&self) -> bool {
|
||||||
|
self.has_open_editor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_dirty_editor(&self) -> bool {
|
||||||
|
self.has_dirty_editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileMutationRequest {
|
||||||
|
pub action: MobileMutationAction,
|
||||||
|
pub source: String,
|
||||||
|
pub revision: String,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub confirmed: bool,
|
||||||
|
pub overwrite: bool,
|
||||||
|
pub discard_editor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileMutationOutcome {
|
||||||
|
action: MobileMutationAction,
|
||||||
|
source: String,
|
||||||
|
destination: Option<String>,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileMutationOutcome {
|
||||||
|
pub fn action(&self) -> MobileMutationAction {
|
||||||
|
self.action
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source(&self) -> &str {
|
||||||
|
&self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destination(&self) -> Option<&str> {
|
||||||
|
self.destination.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MobileMutationService<'a> {
|
||||||
|
repository: &'a Repository,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MobileMutationService<'a> {
|
||||||
|
pub fn new(repository: &'a Repository) -> Self {
|
||||||
|
Self { repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare(
|
||||||
|
&self,
|
||||||
|
source: &str,
|
||||||
|
action: MobileMutationAction,
|
||||||
|
has_open_editor: bool,
|
||||||
|
has_dirty_editor: bool,
|
||||||
|
) -> Result<MobileMutationPlan, MobileMutationError> {
|
||||||
|
let source = EntryPath::parse(source)?;
|
||||||
|
if hidden_path(source.as_path()) {
|
||||||
|
return Err(MobileMutationError::InvalidSource {
|
||||||
|
path: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ciphertext = self.repository.read_entry(&source)?;
|
||||||
|
let snapshot = self.repository.snapshot()?;
|
||||||
|
let source_title = source
|
||||||
|
.as_path()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.expect("a string entry path has a UTF-8 file name")
|
||||||
|
.to_owned();
|
||||||
|
let destinations = if action == MobileMutationAction::Delete {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
snapshot
|
||||||
|
.directories()
|
||||||
|
.filter(|directory| !hidden_path(directory.path().as_path()))
|
||||||
|
.filter_map(|directory| {
|
||||||
|
let destination = EntryPath::parse(
|
||||||
|
directory
|
||||||
|
.path()
|
||||||
|
.as_path()
|
||||||
|
.join(source.as_path().file_name()?),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
(destination != source).then(|| MobileMutationDestination {
|
||||||
|
path: directory_path(directory.path()),
|
||||||
|
title: directory_title(directory.path()),
|
||||||
|
detail: directory_detail(directory.path()),
|
||||||
|
requires_overwrite: snapshot
|
||||||
|
.entries()
|
||||||
|
.any(|entry| entry.path() == &destination),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
Ok(MobileMutationPlan {
|
||||||
|
action,
|
||||||
|
source: source.to_string(),
|
||||||
|
source_title,
|
||||||
|
revision: revision(ciphertext.as_bytes()),
|
||||||
|
destinations,
|
||||||
|
has_open_editor,
|
||||||
|
has_dirty_editor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform(
|
||||||
|
&self,
|
||||||
|
request: &MobileMutationRequest,
|
||||||
|
has_open_editor: bool,
|
||||||
|
keys: &KeyStore,
|
||||||
|
provider: &mut impl SecretProvider,
|
||||||
|
committer: &mut impl TreeCommitter,
|
||||||
|
) -> Result<MobileMutationOutcome, MobileMutationError> {
|
||||||
|
let destination = self.preflight(request, has_open_editor)?;
|
||||||
|
let mutator = TreeMutator::new(self.repository, keys);
|
||||||
|
let overwrite = if request.overwrite {
|
||||||
|
OverwriteDecision::Allow
|
||||||
|
} else {
|
||||||
|
OverwriteDecision::Decline
|
||||||
|
};
|
||||||
|
let outcome = match request.action {
|
||||||
|
MobileMutationAction::Delete => mutator.remove(
|
||||||
|
&RemoveRequest {
|
||||||
|
entry: request.source.clone(),
|
||||||
|
recursive: false,
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
OverwriteDecision::Allow,
|
||||||
|
committer,
|
||||||
|
)?,
|
||||||
|
MobileMutationAction::Move => mutator.move_tree(
|
||||||
|
&MoveRequest {
|
||||||
|
source: request.source.clone(),
|
||||||
|
destination: destination.expect("move destination was validated"),
|
||||||
|
force: request.overwrite,
|
||||||
|
},
|
||||||
|
overwrite,
|
||||||
|
None,
|
||||||
|
provider,
|
||||||
|
committer,
|
||||||
|
)?,
|
||||||
|
MobileMutationAction::Copy => mutator.copy(
|
||||||
|
&CopyRequest {
|
||||||
|
source: request.source.clone(),
|
||||||
|
destination: destination.expect("copy destination was validated"),
|
||||||
|
force: request.overwrite,
|
||||||
|
},
|
||||||
|
overwrite,
|
||||||
|
None,
|
||||||
|
provider,
|
||||||
|
committer,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
let destination = outcome
|
||||||
|
.selection()
|
||||||
|
.map(|selection| selection.display_path());
|
||||||
|
let (title, detail) = match request.action {
|
||||||
|
MobileMutationAction::Move => (
|
||||||
|
"Password Moved",
|
||||||
|
format!(
|
||||||
|
"Moved {} to {}.",
|
||||||
|
request.source,
|
||||||
|
destination.as_deref().unwrap_or("the selected folder")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MobileMutationAction::Copy => (
|
||||||
|
"Password Copied",
|
||||||
|
format!(
|
||||||
|
"Copied {} to {}.",
|
||||||
|
request.source,
|
||||||
|
destination.as_deref().unwrap_or("the selected folder")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MobileMutationAction::Delete => {
|
||||||
|
("Password Deleted", format!("Deleted {}.", request.source))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(MobileMutationOutcome {
|
||||||
|
action: request.action,
|
||||||
|
source: request.source.clone(),
|
||||||
|
destination,
|
||||||
|
title: title.to_owned(),
|
||||||
|
detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preflight(
|
||||||
|
&self,
|
||||||
|
request: &MobileMutationRequest,
|
||||||
|
has_open_editor: bool,
|
||||||
|
) -> Result<Option<String>, MobileMutationError> {
|
||||||
|
let source = EntryPath::parse(&request.source)?;
|
||||||
|
if hidden_path(source.as_path()) {
|
||||||
|
return Err(MobileMutationError::InvalidSource {
|
||||||
|
path: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let current = self.repository.read_entry(&source)?;
|
||||||
|
if revision(current.as_bytes()) != request.revision {
|
||||||
|
return Err(MobileMutationError::StaleEntry {
|
||||||
|
path: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if has_open_editor && !request.discard_editor {
|
||||||
|
return Err(MobileMutationError::EditorOpen {
|
||||||
|
path: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if request.action == MobileMutationAction::Delete {
|
||||||
|
if !request.confirmed {
|
||||||
|
return Err(MobileMutationError::ConfirmationRequired {
|
||||||
|
path: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let destination = request
|
||||||
|
.destination
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(MobileMutationError::DestinationRequired)?;
|
||||||
|
let destination = DirectoryPath::parse(destination)?;
|
||||||
|
if hidden_path(destination.as_path())
|
||||||
|
|| !self
|
||||||
|
.repository
|
||||||
|
.snapshot()?
|
||||||
|
.directories()
|
||||||
|
.any(|directory| directory.path() == &destination)
|
||||||
|
{
|
||||||
|
return Err(MobileMutationError::InvalidDestination {
|
||||||
|
path: directory_path(&destination),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let destination_entry = EntryPath::parse(
|
||||||
|
destination
|
||||||
|
.as_path()
|
||||||
|
.join(source.as_path().file_name().expect("validated source name")),
|
||||||
|
)?;
|
||||||
|
if destination_entry == source {
|
||||||
|
return Err(MobileMutationError::SameDestination);
|
||||||
|
}
|
||||||
|
let destination_exists = match self.repository.read_entry(&destination_entry) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(RepositoryError::NotFound { .. }) => false,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if destination_exists && !request.overwrite {
|
||||||
|
return Err(MobileMutationError::OverwriteRequired {
|
||||||
|
path: destination_entry.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let destination = directory_path(&destination);
|
||||||
|
Ok(Some(if destination.is_empty() {
|
||||||
|
destination
|
||||||
|
} else {
|
||||||
|
format!("{destination}/")
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revision(ciphertext: &[u8]) -> String {
|
||||||
|
HEXLOWER.encode(&Sha256::digest(ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_path(path: &DirectoryPath) -> String {
|
||||||
|
path.as_path().to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_title(path: &DirectoryPath) -> String {
|
||||||
|
path.as_path()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("Password Store")
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_detail(path: &DirectoryPath) -> String {
|
||||||
|
if path.as_path().as_os_str().is_empty() {
|
||||||
|
"Store root".to_owned()
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum MobileMutationError {
|
||||||
|
Repository(RepositoryError),
|
||||||
|
Mutation(MutationError),
|
||||||
|
InvalidSource { path: String },
|
||||||
|
StaleEntry { path: String },
|
||||||
|
EditorOpen { path: String },
|
||||||
|
ConfirmationRequired { path: String },
|
||||||
|
DestinationRequired,
|
||||||
|
InvalidDestination { path: String },
|
||||||
|
SameDestination,
|
||||||
|
OverwriteRequired { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileMutationError {
|
||||||
|
pub fn is_conflict(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::StaleEntry { .. }
|
||||||
|
| Self::EditorOpen { .. }
|
||||||
|
| Self::OverwriteRequired { .. }
|
||||||
|
| Self::Mutation(MutationError::TreeChanged { .. } | MutationError::Commit(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::StaleEntry { .. } => "Password Entry Changed",
|
||||||
|
Self::EditorOpen { .. } => "Password Editor Is Open",
|
||||||
|
Self::ConfirmationRequired { .. } => "Delete Confirmation Required",
|
||||||
|
Self::DestinationRequired => "Destination Required",
|
||||||
|
Self::InvalidDestination { .. } | Self::SameDestination => "Destination Is Invalid",
|
||||||
|
Self::OverwriteRequired { .. } => "Password Already Exists",
|
||||||
|
Self::InvalidSource { .. } => "Password Entry Is Unavailable",
|
||||||
|
Self::Repository(_) | Self::Mutation(_) => "Password Action Failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileMutationError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Repository(error) => error.fmt(formatter),
|
||||||
|
Self::Mutation(error) => error.fmt(formatter),
|
||||||
|
Self::InvalidSource { path } => {
|
||||||
|
write!(formatter, "the selected entry is not mutable: {path}")
|
||||||
|
}
|
||||||
|
Self::StaleEntry { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"{path} changed after the action began; refresh and try again"
|
||||||
|
),
|
||||||
|
Self::EditorOpen { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"close or explicitly discard the open editor for {path}"
|
||||||
|
),
|
||||||
|
Self::ConfirmationRequired { path } => {
|
||||||
|
write!(formatter, "confirm deletion of {path}")
|
||||||
|
}
|
||||||
|
Self::DestinationRequired => formatter.write_str("select a password folder"),
|
||||||
|
Self::InvalidDestination { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"the selected password folder is unavailable: {path}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SameDestination => {
|
||||||
|
formatter.write_str("the source is already in the selected folder")
|
||||||
|
}
|
||||||
|
Self::OverwriteRequired { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"confirm replacement of the existing entry at {path}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileMutationError {}
|
||||||
|
|
||||||
|
impl From<RepositoryError> for MobileMutationError {
|
||||||
|
fn from(error: RepositoryError) -> Self {
|
||||||
|
Self::Repository(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MutationError> for MobileMutationError {
|
||||||
|
fn from(error: MutationError) -> Self {
|
||||||
|
Self::Mutation(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
MobileMutationAction, MobileMutationError, MobileMutationRequest, MobileMutationService,
|
||||||
|
};
|
||||||
|
use crate::repository::Repository;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plans_destinations_collisions_hidden_paths_and_stale_revisions()
|
||||||
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temporary = tempdir()?;
|
||||||
|
fs::create_dir_all(temporary.path().join("Personal"))?;
|
||||||
|
fs::create_dir_all(temporary.path().join("Work"))?;
|
||||||
|
fs::create_dir_all(temporary.path().join(".extensions"))?;
|
||||||
|
fs::write(temporary.path().join("Personal/bank.gpg"), b"source")?;
|
||||||
|
fs::write(temporary.path().join("Work/bank.gpg"), b"collision")?;
|
||||||
|
let repository = Repository::open(temporary.path())?;
|
||||||
|
let service = MobileMutationService::new(&repository);
|
||||||
|
|
||||||
|
let plan = service.prepare("Personal/bank", MobileMutationAction::Move, true, true)?;
|
||||||
|
assert!(plan.has_open_editor());
|
||||||
|
assert!(plan.has_dirty_editor());
|
||||||
|
assert!(!plan.revision().is_empty());
|
||||||
|
assert!(plan.destinations().iter().all(|destination| {
|
||||||
|
destination.path() != "Personal" && !destination.path().starts_with(".extensions")
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
plan.destinations()
|
||||||
|
.iter()
|
||||||
|
.any(|destination| destination.path() == "" && !destination.requires_overwrite())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plan.destinations()
|
||||||
|
.iter()
|
||||||
|
.any(|destination| destination.path() == "Work" && destination.requires_overwrite())
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(temporary.path().join("Personal/bank.gpg"), b"changed")?;
|
||||||
|
let changed = service.prepare("Personal/bank", MobileMutationAction::Move, false, false)?;
|
||||||
|
assert_ne!(plan.revision(), changed.revision());
|
||||||
|
|
||||||
|
let stale = MobileMutationRequest {
|
||||||
|
action: MobileMutationAction::Move,
|
||||||
|
source: plan.source().to_owned(),
|
||||||
|
revision: plan.revision().to_owned(),
|
||||||
|
destination: Some("Work".to_owned()),
|
||||||
|
confirmed: false,
|
||||||
|
overwrite: true,
|
||||||
|
discard_editor: true,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
service.preflight(&stale, false),
|
||||||
|
Err(MobileMutationError::StaleEntry { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let delete = MobileMutationRequest {
|
||||||
|
action: MobileMutationAction::Delete,
|
||||||
|
source: changed.source().to_owned(),
|
||||||
|
revision: changed.revision().to_owned(),
|
||||||
|
destination: None,
|
||||||
|
confirmed: false,
|
||||||
|
overwrite: false,
|
||||||
|
discard_editor: false,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
service.preflight(&delete, false),
|
||||||
|
Err(MobileMutationError::ConfirmationRequired { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
service.preflight(
|
||||||
|
&MobileMutationRequest {
|
||||||
|
confirmed: true,
|
||||||
|
..delete.clone()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
Err(MobileMutationError::EditorOpen { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let collision = MobileMutationRequest {
|
||||||
|
action: MobileMutationAction::Copy,
|
||||||
|
source: changed.source().to_owned(),
|
||||||
|
revision: changed.revision().to_owned(),
|
||||||
|
destination: Some("Work".to_owned()),
|
||||||
|
confirmed: false,
|
||||||
|
overwrite: false,
|
||||||
|
discard_editor: false,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
service.preflight(&collision, false),
|
||||||
|
Err(MobileMutationError::OverwriteRequired { .. })
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user