From 65eb1da71c6462ba49710f3ef7a903da70f22c70 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 14 Aug 2026 17:24:41 +0200 Subject: [PATCH] Present iPhone Git actions (#51) --- apple/Generated/ironstorage_apple.swift | 293 +++++++++++++-- apple/Generated/ironstorage_appleFFI.h | 37 +- apple/Sources/App/IronStorageApp.swift | 182 +++++++++- crates/apple/src/lib.rs | 88 ++++- crates/storage/src/git.rs | 19 + crates/storage/src/mobile_authentication.rs | 128 ++++++- crates/storage/src/mobile_home.rs | 380 ++++++++++++++++++-- crates/storage/tests/git_embedded.rs | 17 + 8 files changed, 1054 insertions(+), 90 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 0c1d688..e83ee62 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -1099,11 +1099,15 @@ public protocol MobileHomeOperationProtocol: AnyObject, Sendable { func cancel() + func commit(message: String) throws -> MobileHomePage + + func fetch() throws -> MobileHomePage + func progress() -> MobileHomeProgress func pull() throws -> MobileHomePage - func refresh() throws -> MobileHomePage + func push() throws -> MobileHomePage func refreshIfStale() throws -> MobileHomePage @@ -1178,6 +1182,25 @@ open func cancel() {try! rustCall() { } } +open func commit(message: String)throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_commit( + self.uniffiCloneHandle(), + FfiConverterString.lower(message),uniffiCallStatus + ) +}) +} + +open func fetch()throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_fetch( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func progress() -> MobileHomeProgress { return try! FfiConverterTypeMobileHomeProgress_lift(try! rustCall() { uniffiCallStatus in @@ -1196,10 +1219,10 @@ open func pull()throws -> MobileHomePage { }) } -open func refresh()throws -> MobileHomePage { +open func push()throws -> MobileHomePage { return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { uniffiCallStatus in - uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh( + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_push( self.uniffiCloneHandle(),uniffiCallStatus ) }) @@ -2527,6 +2550,64 @@ public func FfiConverterTypeMobileGitIdentity_lower(_ value: MobileGitIdentity) } +public struct MobileHomeAction: Equatable, Hashable { + public var kind: MobileHomeActionKind + public var title: String + public var systemImage: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(kind: MobileHomeActionKind, title: String, systemImage: String) { + self.kind = kind + self.title = title + self.systemImage = systemImage + } + + + + +} + +#if compiler(>=6) +extension MobileHomeAction: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeAction: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeAction { + return + try MobileHomeAction( + kind: FfiConverterTypeMobileHomeActionKind.read(from: &buf), + title: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeAction, into buf: inout [UInt8]) { + FfiConverterTypeMobileHomeActionKind.write(value.kind, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeAction_lift(_ buf: RustBuffer) throws -> MobileHomeAction { + return try FfiConverterTypeMobileHomeAction.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeAction_lower(_ value: MobileHomeAction) -> RustBuffer { + return FfiConverterTypeMobileHomeAction.lower(value) +} + + public struct MobileHomeChange: Equatable, Hashable { public var id: String public var title: String @@ -2604,16 +2685,18 @@ public struct MobileHomeCommit: Equatable, Hashable { public var systemImage: String public var timestamp: Int64 public var changes: [MobileHomeChange] + public var actions: [MobileHomeAction] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, title: String, detail: String, systemImage: String, timestamp: Int64, changes: [MobileHomeChange]) { + public init(id: String, title: String, detail: String, systemImage: String, timestamp: Int64, changes: [MobileHomeChange], actions: [MobileHomeAction]) { self.id = id self.title = title self.detail = detail self.systemImage = systemImage self.timestamp = timestamp self.changes = changes + self.actions = actions } @@ -2637,7 +2720,8 @@ public struct FfiConverterTypeMobileHomeCommit: FfiConverterRustBuffer { detail: FfiConverterString.read(from: &buf), systemImage: FfiConverterString.read(from: &buf), timestamp: FfiConverterInt64.read(from: &buf), - changes: FfiConverterSequenceTypeMobileHomeChange.read(from: &buf) + changes: FfiConverterSequenceTypeMobileHomeChange.read(from: &buf), + actions: FfiConverterSequenceTypeMobileHomeAction.read(from: &buf) ) } @@ -2648,6 +2732,7 @@ public struct FfiConverterTypeMobileHomeCommit: FfiConverterRustBuffer { FfiConverterString.write(value.systemImage, into: &buf) FfiConverterInt64.write(value.timestamp, into: &buf) FfiConverterSequenceTypeMobileHomeChange.write(value.changes, into: &buf) + FfiConverterSequenceTypeMobileHomeAction.write(value.actions, into: &buf) } } @@ -2804,13 +2889,15 @@ public func FfiConverterTypeMobileHomePage_lower(_ value: MobileHomePage) -> Rus public struct MobileHomeProgress: Equatable, Hashable { + public var action: MobileHomeActionKind? public var phase: MobileHomePhase public var title: String public var detail: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(phase: MobileHomePhase, title: String, detail: String) { + public init(action: MobileHomeActionKind?, phase: MobileHomePhase, title: String, detail: String) { + self.action = action self.phase = phase self.title = title self.detail = detail @@ -2832,6 +2919,7 @@ public struct FfiConverterTypeMobileHomeProgress: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeProgress { return try MobileHomeProgress( + action: FfiConverterOptionTypeMobileHomeActionKind.read(from: &buf), phase: FfiConverterTypeMobileHomePhase.read(from: &buf), title: FfiConverterString.read(from: &buf), detail: FfiConverterString.read(from: &buf) @@ -2839,6 +2927,7 @@ public struct FfiConverterTypeMobileHomeProgress: FfiConverterRustBuffer { } public static func write(_ value: MobileHomeProgress, into buf: inout [UInt8]) { + FfiConverterOptionTypeMobileHomeActionKind.write(value.action, into: &buf) FfiConverterTypeMobileHomePhase.write(value.phase, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.detail, into: &buf) @@ -2866,14 +2955,16 @@ public struct MobileHomeSummaryRow: Equatable, Hashable { public var title: String public var detail: String public var systemImage: String + public var actions: [MobileHomeAction] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, title: String, detail: String, systemImage: String) { + public init(id: String, title: String, detail: String, systemImage: String, actions: [MobileHomeAction]) { self.id = id self.title = title self.detail = detail self.systemImage = systemImage + self.actions = actions } @@ -2895,7 +2986,8 @@ public struct FfiConverterTypeMobileHomeSummaryRow: FfiConverterRustBuffer { id: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf), detail: FfiConverterString.read(from: &buf), - systemImage: FfiConverterString.read(from: &buf) + systemImage: FfiConverterString.read(from: &buf), + actions: FfiConverterSequenceTypeMobileHomeAction.read(from: &buf) ) } @@ -2904,6 +2996,7 @@ public struct FfiConverterTypeMobileHomeSummaryRow: FfiConverterRustBuffer { FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.detail, into: &buf) FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterSequenceTypeMobileHomeAction.write(value.actions, into: &buf) } } @@ -4619,6 +4712,86 @@ public func FfiConverterTypeMobileEntrySectionKind_lower(_ value: MobileEntrySec +public enum MobileHomeActionKind: Equatable, Hashable { + + case commit + case fetch + case pull + case push + + + + + +} + +#if compiler(>=6) +extension MobileHomeActionKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeActionKind: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeActionKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeActionKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .commit + + case 2: return .fetch + + case 3: return .pull + + case 4: return .push + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeActionKind, into buf: inout [UInt8]) { + switch value { + + + case .commit: + writeInt(&buf, Int32(1)) + + + case .fetch: + writeInt(&buf, Int32(2)) + + + case .pull: + writeInt(&buf, Int32(3)) + + + case .push: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeActionKind_lift(_ buf: RustBuffer) throws -> MobileHomeActionKind { + return try FfiConverterTypeMobileHomeActionKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeActionKind_lower(_ value: MobileHomeActionKind) -> RustBuffer { + return FfiConverterTypeMobileHomeActionKind.lower(value) +} + + + + public enum MobileHomeChangeKind: Equatable, Hashable { case passwordEntry @@ -4779,6 +4952,7 @@ public enum MobileHomeErrorKind: Equatable, Hashable { case authentication case conflict case dirtyLocalChanges + case noChanges case offline case interrupted case secureStorage @@ -4815,15 +4989,17 @@ public struct FfiConverterTypeMobileHomeErrorKind: FfiConverterRustBuffer { case 5: return .dirtyLocalChanges - case 6: return .offline + case 6: return .noChanges - case 7: return .interrupted + case 7: return .offline - case 8: return .secureStorage + case 8: return .interrupted - case 9: return .repository + case 9: return .secureStorage - case 10: return .partialProgress + case 10: return .repository + + case 11: return .partialProgress default: throw UniffiInternalError.unexpectedEnumCase } @@ -4853,25 +5029,29 @@ public struct FfiConverterTypeMobileHomeErrorKind: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) - case .offline: + case .noChanges: writeInt(&buf, Int32(6)) - case .interrupted: + case .offline: writeInt(&buf, Int32(7)) - case .secureStorage: + case .interrupted: writeInt(&buf, Int32(8)) - case .repository: + case .secureStorage: writeInt(&buf, Int32(9)) - case .partialProgress: + case .repository: writeInt(&buf, Int32(10)) + + case .partialProgress: + writeInt(&buf, Int32(11)) + } } } @@ -5052,6 +5232,7 @@ public enum MobileHomePhase: Equatable, Hashable { case authenticating case receiving case integrating + case sending case finishing @@ -5082,7 +5263,9 @@ public struct FfiConverterTypeMobileHomePhase: FfiConverterRustBuffer { case 4: return .integrating - case 5: return .finishing + case 5: return .sending + + case 6: return .finishing default: throw UniffiInternalError.unexpectedEnumCase } @@ -5108,9 +5291,13 @@ public struct FfiConverterTypeMobileHomePhase: FfiConverterRustBuffer { writeInt(&buf, Int32(4)) - case .finishing: + case .sending: writeInt(&buf, Int32(5)) + + case .finishing: + writeInt(&buf, Int32(6)) + } } } @@ -6428,6 +6615,30 @@ fileprivate struct FfiConverterOptionTypeMobileTotpPage: FfiConverterRustBuffer } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeMobileHomeActionKind: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeActionKind? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeMobileHomeActionKind.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeMobileHomeActionKind.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -6553,6 +6764,31 @@ fileprivate struct FfiConverterSequenceTypeMobileEntrySection: FfiConverterRustB } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileHomeAction: FfiConverterRustBuffer { + typealias SwiftType = [MobileHomeAction] + + public static func write(_ value: [MobileHomeAction], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileHomeAction.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileHomeAction] { + let len: Int32 = try readInt(&buf) + var seq = [MobileHomeAction]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileHomeAction.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -6784,10 +7020,11 @@ public func mobileAuthentication()throws -> MobileAuthentication { ) }) } -public func mobileHomeOperation() -> MobileHomeOperation { +public func mobileHomeOperation(authentication: MobileAuthentication) -> MobileHomeOperation { return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { uniffiCallStatus in - uniffi_ironstorage_apple_fn_func_mobile_home_operation(uniffiCallStatus + uniffi_ironstorage_apple_fn_func_mobile_home_operation( + FfiConverterTypeMobileAuthentication_lower(authentication),uniffiCallStatus ) }) } @@ -6888,7 +7125,7 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_mobile_authentication() != 38258) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) { + if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 64540) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_func_mobile_key_transfer() != 57389) { @@ -7020,13 +7257,19 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel() != 13921) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_commit() != 28659) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_fetch() != 29855) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_progress() != 4980) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_pull() != 9011) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh() != 50081) { + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_push() != 33176) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale() != 27818) { @@ -7082,4 +7325,4 @@ public func uniffiEnsureIronstorageAppleInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index f70b4f6..8e37efb 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -428,6 +428,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached(uint64_ void uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT +RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_commit(uint64_t ptr, RustBuffer message, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH +RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_fetch(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -438,9 +448,9 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint6 RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH -#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH -RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64_t ptr, RustCallStatus *_Nonnull out_status +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH +RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_push(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE @@ -551,8 +561,7 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus * #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION -uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus *_Nonnull out_status - +uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(uint64_t authentication, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER @@ -1137,6 +1146,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached(voi #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_commit(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_fetch(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS @@ -1151,9 +1172,9 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_pull(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH -#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH -uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(void +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_push(void ); #endif diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index df51ec1..7d0d1fb 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -93,8 +93,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { TotpListViewController(shellPage: page, authentication: authentication) case .preferences: PreferencesViewController(page: page, authentication: authentication) - default: - ShellViewController(page: page) + case .home: + ShellViewController(page: page, authentication: authentication) } let navigation = UINavigationController(rootViewController: root) navigation.navigationBar.prefersLargeTitles = true @@ -182,6 +182,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { @MainActor private final class ShellViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab: MobileTab + private let authentication: MobileAuthentication? private var page: MobilePage private var loadTask: Task? private var loadGeneration = 0 @@ -202,12 +203,20 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { private enum HomeRequest: Equatable { case refreshIfStale - case refresh + case commit(String) + case fetch case pull + case push + + var changesLocalStore: Bool { + if case .pull = self { return true } + return false + } } - init(page: MobilePage) { + init(page: MobilePage, authentication: MobileAuthentication?) { shellTab = page.tab + self.authentication = authentication self.page = page super.init(style: .insetGrouped) title = page.title @@ -344,6 +353,43 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { ) } + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + let actions = homeActions(at: indexPath).map { action in + let contextual = UIContextualAction(style: .normal, title: action.title) { + [weak self] _, _, completion in + self?.perform(action) + completion(true) + } + contextual.image = UIImage(systemName: action.systemImage) + contextual.backgroundColor = actionColor(action.kind) + return contextual + } + guard !actions.isEmpty else { return nil } + let configuration = UISwipeActionsConfiguration(actions: actions) + configuration.performsFirstActionWithFullSwipe = actions.count == 1 + return configuration + } + + override func tableView( + _ tableView: UITableView, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + let actions = homeActions(at: indexPath) + guard !actions.isEmpty else { return nil } + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + UIMenu(children: actions.map { action in + UIAction( + title: action.title, + image: UIImage(systemName: action.systemImage) + ) { [weak self] _ in self?.perform(action) } + }) + } + } + @objc private func refreshRequested() { if shellTab == .home, page.state == .ready { guard !isHomeWorking else { @@ -434,7 +480,13 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { } @objc private func statusRefreshRequested() { - runHome(.refresh) + runHome(.fetch) + } + + @objc private func cancelHomeRequested() { + homeOperation?.cancel() + navigationItem.leftBarButtonItem?.isEnabled = false + navigationItem.prompt = "Cancelling the current Git action…" } private var homeSections: [HomeSection] { @@ -466,18 +518,25 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { content.text = row.title content.secondaryText = row.detail cell.selectionStyle = .none + cell.accessibilityCustomActions = accessibilityActions(row.actions) case .incoming: configureCommit( homePage.incoming[indexPath.row], content: &content, cell: cell ) + cell.accessibilityCustomActions = accessibilityActions( + homePage.incoming[indexPath.row].actions + ) case .outgoing: configureCommit( homePage.outgoing[indexPath.row], content: &content, cell: cell ) + cell.accessibilityCustomActions = accessibilityActions( + homePage.outgoing[indexPath.row].actions + ) case .empty: content.image = UIImage(systemName: "checkmark.circle") content.text = "No Remote Activity" @@ -533,7 +592,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { guard homePage == nil, homeTask == nil, !isHomeWorking else { return } homeGeneration += 1 let current = homeGeneration - let operation = mobileHomeOperation() + guard let authentication else { + showHomeFailure(.unavailable) + return + } + let operation = mobileHomeOperation(authentication: authentication) homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false @@ -558,10 +621,14 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { private func runHome(_ request: HomeRequest) { guard page.state == .ready, shellTab == .home, !isHomeWorking else { return } + guard let authentication else { + showHomeFailure(.unavailable) + return + } cancelHomeWork() homeGeneration += 1 let current = homeGeneration - let operation = mobileHomeOperation() + let operation = mobileHomeOperation(authentication: authentication) homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false @@ -582,8 +649,10 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { do { let page: MobileHomePage = switch request { case .refreshIfStale: try operation.refreshIfStale() - case .refresh: try operation.refresh() + case let .commit(message): try operation.commit(message: message) + case .fetch: try operation.fetch() case .pull: try operation.pull() + case .push: try operation.push() } return Result.success(page) } catch let error as MobileHomeFfiError { @@ -609,24 +678,32 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { navigationItem.titleView = nil navigationItem.prompt = nil navigationItem.rightBarButtonItem?.isEnabled = true + navigationItem.leftBarButtonItem = nil refreshControl?.endRefreshing() switch result { case let .success(homePage): self.homePage = homePage contentUnavailableConfiguration = nil tableView.reloadData() - if request == .pull { + if request?.changesLocalStore == true { NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) - if let notice = homePage.notice { - UIAccessibility.post(notification: .announcement, argument: notice.title) - } + NotificationCenter.default.post( + name: .ironStorageWatchSnapshotDidChange, + object: nil + ) + } + if let notice = homePage.notice { + UIAccessibility.post(notification: .announcement, argument: notice.title) } case let .failure(failure): tableView.reloadData() if homePage == nil { showHomeFailure(failure) - } else if failure.kind != .interrupted { - presentHomeFailure(failure) + presentHomeFailure(failure, retry: request ?? .fetch) + } else if failure.kind == .interrupted { + UIAccessibility.post(notification: .announcement, argument: failure.title) + } else { + presentHomeFailure(failure, retry: request ?? .fetch) } } } @@ -646,7 +723,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { contentUnavailableConfiguration = configuration } - private func presentHomeFailure(_ failure: HomeFailure) { + private func presentHomeFailure(_ failure: HomeFailure, retry: HomeRequest) { let alert = UIAlertController( title: failure.title, message: failure.detail, @@ -661,6 +738,9 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { } ?? tabs.selectedIndex }) } + alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in + self?.runHome(retry) + }) alert.addAction(UIAlertAction(title: "OK", style: .cancel)) present(alert, animated: true) } @@ -677,6 +757,12 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { stack.spacing = 8 navigationItem.titleView = stack navigationItem.prompt = progress.detail + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Cancel", + style: .plain, + target: self, + action: #selector(cancelHomeRequested) + ) } private func cancelHomeWork() { @@ -690,10 +776,70 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { isHomeWorking = false navigationItem.titleView = nil navigationItem.prompt = nil + navigationItem.leftBarButtonItem = nil refreshControl?.endRefreshing() navigationItem.rightBarButtonItem?.isEnabled = true } + private func homeActions(at indexPath: IndexPath) -> [MobileHomeAction] { + guard let homePage, homeSections.indices.contains(indexPath.section) else { return [] } + return switch homeSections[indexPath.section] { + case .summary where homePage.summaries.indices.contains(indexPath.row): + homePage.summaries[indexPath.row].actions + case .incoming where homePage.incoming.indices.contains(indexPath.row): + homePage.incoming[indexPath.row].actions + case .outgoing where homePage.outgoing.indices.contains(indexPath.row): + homePage.outgoing[indexPath.row].actions + default: + [] + } + } + + private func perform(_ action: MobileHomeAction) { + switch action.kind { + case .commit: promptForCommitMessage() + case .fetch: runHome(.fetch) + case .pull: runHome(.pull) + case .push: runHome(.push) + } + } + + private func promptForCommitMessage() { + let alert = UIAlertController( + title: "Commit Changes", + message: "Enter the message for all current password-store changes.", + preferredStyle: .alert + ) + alert.addTextField { field in + field.placeholder = "Commit message" + field.autocapitalizationType = .sentences + field.returnKeyType = .done + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Commit", style: .default) { [weak self, weak alert] _ in + self?.runHome(.commit(alert?.textFields?.first?.text ?? "")) + }) + present(alert, animated: true) + } + + private func accessibilityActions(_ actions: [MobileHomeAction]) -> [UIAccessibilityCustomAction] { + actions.map { action in + UIAccessibilityCustomAction(name: action.title) { [weak self] _ in + self?.perform(action) + return true + } + } + } + + private func actionColor(_ kind: MobileHomeActionKind) -> UIColor { + switch kind { + case .commit: .systemOrange + case .fetch: .systemGray + case .pull: .systemBlue + case .push: .systemGreen + } + } + private func stateImage(_ state: MobileShellState) -> String { switch state { case .loading: "hourglass" @@ -5275,6 +5421,12 @@ private struct HomeFailure: Error, Sendable { detail: "IronStorage could not load the storage-provided Home page." ) + static let unavailable = HomeFailure( + kind: .configuration, + title: "Home Is Unavailable", + detail: "Complete password-store setup before using Git actions." + ) + private init(kind: MobileHomeErrorKind, title: String, detail: String) { self.kind = kind self.title = title diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 1e9b4dd..ddb6dbc 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -26,7 +26,8 @@ use ironstorage::{ MobileEntrySectionKind as StorageEntrySectionKind, }, mobile_home::{ - self, MobileHomeChangeKind as StorageHomeChangeKind, + self, MobileHomeActionKind as StorageHomeActionKind, + MobileHomeChangeKind as StorageHomeChangeKind, MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError, MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness, MobileHomePhase as StorageHomePhase, @@ -189,6 +190,32 @@ pub enum MobileHomeChangeStatus { Deleted, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomeActionKind { + Commit, + Fetch, + Pull, + Push, +} + +impl From for MobileHomeActionKind { + fn from(kind: StorageHomeActionKind) -> Self { + match kind { + StorageHomeActionKind::Commit => Self::Commit, + StorageHomeActionKind::Fetch => Self::Fetch, + StorageHomeActionKind::Pull => Self::Pull, + StorageHomeActionKind::Push => Self::Push, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeAction { + pub kind: MobileHomeActionKind, + pub title: String, + pub system_image: String, +} + impl From for MobileHomeChangeStatus { fn from(status: StorageHomeChangeStatus) -> Self { match status { @@ -205,6 +232,7 @@ pub struct MobileHomeSummaryRow { pub title: String, pub detail: String, pub system_image: String, + pub actions: Vec, } #[derive(Clone, uniffi::Record)] @@ -225,6 +253,7 @@ pub struct MobileHomeCommit { pub system_image: String, pub timestamp: i64, pub changes: Vec, + pub actions: Vec, } #[derive(Clone, uniffi::Record)] @@ -259,6 +288,7 @@ impl From for MobileHomePage { title: row.title().to_owned(), detail: row.detail().to_owned(), system_image: row.system_image().to_owned(), + actions: row.actions().iter().map(mobile_home_action).collect(), }) .collect(), incoming: page.incoming().iter().map(mobile_home_commit).collect(), @@ -293,6 +323,15 @@ fn mobile_home_commit(commit: &mobile_home::MobileHomeCommit) -> MobileHomeCommi status: change.status().into(), }) .collect(), + actions: commit.actions().iter().map(mobile_home_action).collect(), + } +} + +fn mobile_home_action(action: &mobile_home::MobileHomeAction) -> MobileHomeAction { + MobileHomeAction { + kind: action.kind().into(), + title: action.title().to_owned(), + system_image: action.system_image().to_owned(), } } @@ -302,6 +341,7 @@ pub enum MobileHomePhase { Authenticating, Receiving, Integrating, + Sending, Finishing, } @@ -312,6 +352,7 @@ impl From for MobileHomePhase { StorageHomePhase::Authenticating => Self::Authenticating, StorageHomePhase::Receiving => Self::Receiving, StorageHomePhase::Integrating => Self::Integrating, + StorageHomePhase::Sending => Self::Sending, StorageHomePhase::Finishing => Self::Finishing, } } @@ -319,6 +360,7 @@ impl From for MobileHomePhase { #[derive(Clone, uniffi::Record)] pub struct MobileHomeProgress { + pub action: Option, pub phase: MobileHomePhase, pub title: String, pub detail: String, @@ -331,6 +373,7 @@ pub enum MobileHomeErrorKind { Authentication, Conflict, DirtyLocalChanges, + NoChanges, Offline, Interrupted, SecureStorage, @@ -346,6 +389,7 @@ impl From for MobileHomeErrorKind { StorageHomeErrorKind::Authentication => Self::Authentication, StorageHomeErrorKind::Conflict => Self::Conflict, StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges, + StorageHomeErrorKind::NoChanges => Self::NoChanges, StorageHomeErrorKind::Offline => Self::Offline, StorageHomeErrorKind::Interrupted => Self::Interrupted, StorageHomeErrorKind::SecureStorage => Self::SecureStorage, @@ -394,6 +438,7 @@ impl MobileHomeOperation { pub fn progress(&self) -> MobileHomeProgress { let progress = self.operation.progress(); MobileHomeProgress { + action: progress.action().map(Into::into), phase: progress.phase().into(), title: progress.title().to_owned(), detail: progress.detail().to_owned(), @@ -411,14 +456,25 @@ impl MobileHomeOperation { .map_err(Into::into) } - pub fn refresh(&self) -> Result { - self.operation.refresh().map(Into::into).map_err(Into::into) + pub fn commit(&self, message: String) -> Result { + self.operation + .commit(&message) + .map(Into::into) + .map_err(Into::into) + } + + pub fn fetch(&self) -> Result { + self.operation.fetch().map(Into::into).map_err(Into::into) } pub fn pull(&self) -> Result { self.operation.pull().map(Into::into).map_err(Into::into) } + pub fn push(&self) -> Result { + self.operation.push().map(Into::into).map_err(Into::into) + } + pub fn cancel(&self) { self.operation.cancel(); } @@ -1322,7 +1378,7 @@ impl From for MobileAuthenticationFfiError { #[derive(uniffi::Object)] pub struct MobileAuthentication { - authentication: ironstorage::mobile_authentication::MobileAuthentication, + authentication: Arc, } #[uniffi::export] @@ -1855,9 +1911,13 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr } #[uniffi::export] -pub fn mobile_home_operation() -> Arc { +pub fn mobile_home_operation( + authentication: Arc, +) -> Arc { Arc::new(MobileHomeOperation { - operation: mobile_home::MobileHomeOperation::default(), + operation: mobile_home::MobileHomeOperation::new(Arc::clone( + &authentication.authentication, + )), }) } @@ -1887,7 +1947,7 @@ pub fn mobile_password_search(query: String) -> Result Result, MobileAuthenticationFfiError> { Ok(Arc::new(MobileAuthentication { - authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?, + authentication: Arc::new(ironstorage::mobile_authentication::MobileAuthentication::load()?), })) } @@ -1931,8 +1991,8 @@ pub fn replace_configured_mobile_application_token( #[cfg(test)] mod tests { use super::{ - MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, - MobileTab, + MobileHomeActionKind, MobileOnboardingErrorKind, MobileOnboardingFfiError, + MobileShellState, MobileTab, StorageHomeActionKind, }; #[test] @@ -1976,10 +2036,10 @@ mod tests { } #[test] - fn home_operation_exposes_typed_progress_and_cancellation() { - let operation = super::mobile_home_operation(); - assert_eq!(operation.progress().phase, MobileHomePhase::Validating); - operation.cancel(); - assert_eq!(operation.progress().phase, MobileHomePhase::Validating); + fn home_bridge_preserves_storage_action_kinds() { + assert_eq!( + MobileHomeActionKind::from(StorageHomeActionKind::Push), + MobileHomeActionKind::Push + ); } } diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 50479f1..c01fad5 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -2255,6 +2255,25 @@ impl GitRepository { self.commit_tree(message, tree) } + /// Stage every current worktree/index change and create one user-requested + /// commit without exposing staging policy to a frontend. + pub fn commit_all(&self, message: &str) -> Result { + let status = self.status()?; + let paths = status + .staged() + .iter() + .chain(status.unstaged()) + .map(|change| change.path().to_owned()) + .collect::>() + .into_iter() + .collect::>(); + if paths.is_empty() { + return Err(GitError::NoChanges); + } + self.stage_and_commit(&paths, message)? + .ok_or(GitError::NoChanges) + } + fn commit_tree(&self, message: &str, tree: gix::hash::ObjectId) -> Result { validate_commit_message(message)?; let parent = self.repository.head_id().ok().map(|id| id.detach()); diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 94b64bb..b10baea 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -195,9 +195,38 @@ struct MobileAuthenticationStatus { next_editor_id: u64, editors: BTreeMap, mutation_active: bool, + repository_operation_active: bool, watch_shared_totp_entries: std::collections::BTreeSet, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MobileRepositoryOperation { + Commit, + Fetch, + Pull, + Push, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MobileRepositoryOperationError { + Busy, + DirtyEditor, + OpenEditor, + Unavailable, +} + +pub(crate) struct MobileRepositoryOperationGuard<'a> { + authentication: &'a MobileAuthentication, +} + +impl Drop for MobileRepositoryOperationGuard<'_> { + fn drop(&mut self) { + if let Ok(mut status) = self.authentication.status.lock() { + status.repository_operation_active = false; + } + } +} + /// One process-wide mobile authentication lease shared by every tab and viewer. pub struct MobileAuthentication { config: Config, @@ -232,6 +261,7 @@ impl MobileAuthentication { next_editor_id: 0, editors: BTreeMap::new(), mutation_active: false, + repository_operation_active: false, watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), }), config, @@ -667,6 +697,7 @@ impl MobileAuthentication { field: u64, value: String, ) -> Result { + self.ensure_repository_idle()?; let mut document = self.open_active_document(path)?; document .replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) @@ -824,6 +855,7 @@ impl MobileAuthentication { fields: Vec, ) -> Result { self.ensure_active()?; + self.ensure_repository_idle()?; let mut draft = self .status()? .editors @@ -939,10 +971,10 @@ impl MobileAuthentication { draft: MobileEntryDraft, ) -> Result { let mut status = self.status()?; - if status.mutation_active { + if status.mutation_active || status.repository_operation_active { return Err(entry_detail( "Password Action In Progress", - "wait for the current move, copy, or delete action to finish", + "wait for the current password-store action to finish", )); } let id = status.next_editor_id; @@ -960,7 +992,7 @@ impl MobileAuthentication { 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 { + if status.mutation_active || status.repository_operation_active { return Err(MobileAuthenticationError::new( MobileAuthenticationErrorKind::Conflict, "Password Action In Progress", @@ -971,6 +1003,44 @@ impl MobileAuthentication { Ok(()) } + pub(crate) fn reserve_repository_operation( + &self, + operation: MobileRepositoryOperation, + ) -> Result, MobileRepositoryOperationError> { + let mut status = self + .status + .lock() + .map_err(|_| MobileRepositoryOperationError::Unavailable)?; + let dirty_editor = status + .editors + .values() + .any(|draft| draft.document().is_modified()); + if let Some(error) = repository_operation_conflict( + operation, + status.mutation_active || status.repository_operation_active, + !status.editors.is_empty(), + dirty_editor, + ) { + return Err(error); + } + status.repository_operation_active = true; + drop(status); + Ok(MobileRepositoryOperationGuard { + authentication: self, + }) + } + + fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> { + if self.status()?.repository_operation_active { + Err(entry_detail( + "Repository Action In Progress", + "wait for the current commit, fetch, pull, or push action to finish", + )) + } else { + Ok(()) + } + } + fn release_entry_mutation(&self) { if let Ok(mut status) = self.status.lock() { status.mutation_active = false; @@ -1061,6 +1131,27 @@ impl MobileAuthentication { } } +fn repository_operation_conflict( + operation: MobileRepositoryOperation, + busy: bool, + open_editor: bool, + dirty_editor: bool, +) -> Option { + if busy { + Some(MobileRepositoryOperationError::Busy) + } else if operation == MobileRepositoryOperation::Pull && open_editor { + Some(if dirty_editor { + MobileRepositoryOperationError::DirtyEditor + } else { + MobileRepositoryOperationError::OpenEditor + }) + } else if operation == MobileRepositoryOperation::Push && dirty_editor { + Some(MobileRepositoryOperationError::DirtyEditor) + } else { + None + } +} + struct KeyOnlyProvider<'a> { handle: NativeAuthenticationHandle, fingerprint: &'a str, @@ -1198,3 +1289,34 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr error.to_string(), ) } + +#[cfg(test)] +mod tests { + use super::{ + MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict, + }; + + #[test] + fn repository_actions_protect_editors_and_serialize_mutations() { + assert_eq!( + repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, false), + Some(MobileRepositoryOperationError::OpenEditor) + ); + assert_eq!( + repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, true), + Some(MobileRepositoryOperationError::DirtyEditor) + ); + assert_eq!( + repository_operation_conflict(MobileRepositoryOperation::Push, false, true, true), + Some(MobileRepositoryOperationError::DirtyEditor) + ); + assert_eq!( + repository_operation_conflict(MobileRepositoryOperation::Fetch, false, true, true), + None + ); + assert_eq!( + repository_operation_conflict(MobileRepositoryOperation::Commit, true, false, false), + Some(MobileRepositoryOperationError::Busy) + ); + } +} diff --git a/crates/storage/src/mobile_home.rs b/crates/storage/src/mobile_home.rs index 72c3842..fe3a07f 100644 --- a/crates/storage/src/mobile_home.rs +++ b/crates/storage/src/mobile_home.rs @@ -12,8 +12,11 @@ use std::{ use crate::{ config::{Config, ConfigError, GitRemote}, git::{ - GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl, - GitProgressPhase, GitRepository, PullOutcome, + FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError, + GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, PushOutcome, + }, + mobile_authentication::{ + MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError, }, repository::{Repository, RepositoryError}, secret_store::{ @@ -46,6 +49,35 @@ pub enum MobileHomeChangeStatus { Deleted, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomeActionKind { + Commit, + Fetch, + Pull, + Push, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeAction { + kind: MobileHomeActionKind, + title: &'static str, + system_image: &'static str, +} + +impl MobileHomeAction { + pub fn kind(&self) -> MobileHomeActionKind { + self.kind + } + + pub fn title(&self) -> &str { + self.title + } + + pub fn system_image(&self) -> &str { + self.system_image + } +} + impl From for MobileHomeChangeStatus { fn from(kind: GitChangeKind) -> Self { match kind { @@ -62,6 +94,7 @@ pub struct MobileHomeSummaryRow { title: String, detail: String, system_image: String, + actions: Vec, } impl MobileHomeSummaryRow { @@ -80,6 +113,10 @@ impl MobileHomeSummaryRow { pub fn system_image(&self) -> &str { &self.system_image } + + pub fn actions(&self) -> &[MobileHomeAction] { + &self.actions + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -126,6 +163,7 @@ pub struct MobileHomeCommit { system_image: String, timestamp: i64, changes: Vec, + actions: Vec, } impl MobileHomeCommit { @@ -152,6 +190,10 @@ impl MobileHomeCommit { pub fn changes(&self) -> &[MobileHomeChange] { &self.changes } + + pub fn actions(&self) -> &[MobileHomeAction] { + &self.actions + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -227,6 +269,7 @@ pub enum MobileHomePhase { Authenticating, Receiving, Integrating, + Sending, Finishing, } @@ -236,7 +279,8 @@ impl From for MobileHomePhase { GitProgressPhase::Validating => Self::Validating, GitProgressPhase::Authenticating => Self::Authenticating, GitProgressPhase::Receiving => Self::Receiving, - GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating, + GitProgressPhase::Integrating => Self::Integrating, + GitProgressPhase::Sending => Self::Sending, GitProgressPhase::Refreshing => Self::Finishing, } } @@ -244,12 +288,17 @@ impl From for MobileHomePhase { #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomeProgress { + action: Option, phase: MobileHomePhase, title: String, detail: String, } impl MobileHomeProgress { + pub fn action(&self) -> Option { + self.action + } + pub fn phase(&self) -> MobileHomePhase { self.phase } @@ -264,32 +313,34 @@ impl MobileHomeProgress { } pub struct MobileHomeOperation { + authentication: Arc, control: GitOperationControl, phase: Arc>, + action: Mutex>, } -impl Default for MobileHomeOperation { - fn default() -> Self { +impl MobileHomeOperation { + pub fn new(authentication: Arc) -> Self { let phase = Arc::new(Mutex::new(MobileHomePhase::Validating)); let observed = Arc::clone(&phase); Self { + authentication, control: GitOperationControl::new(move |phase| { if let Ok(mut current) = observed.lock() { *current = phase.into(); } }), phase, + action: Mutex::new(None), } } -} - -impl MobileHomeOperation { pub fn cancel(&self) { self.control.cancel(); } pub fn progress(&self) -> MobileHomeProgress { progress_copy( + self.action.lock().ok().and_then(|action| *action), self.phase .lock() .map_or(MobileHomePhase::Validating, |phase| *phase), @@ -320,15 +371,53 @@ impl MobileHomeOperation { None, ); } - storage.refresh(now, &self.control) + self.run(MobileHomeActionKind::Fetch, || { + storage.fetch(now, &self.control) + }) } - pub fn refresh(&self) -> Result { - MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control) + pub fn commit(&self, message: &str) -> Result { + if message.trim().is_empty() || message.contains('\0') { + return Err(MobileHomeError::invalid_commit_message()); + } + self.run(MobileHomeActionKind::Commit, || { + MobileHomeStorage::load()?.commit(message, &self.control) + }) + } + + pub fn fetch(&self) -> Result { + self.run(MobileHomeActionKind::Fetch, || { + MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control) + }) } pub fn pull(&self) -> Result { - MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) + self.run(MobileHomeActionKind::Pull, || { + MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) + }) + } + + pub fn push(&self) -> Result { + self.run(MobileHomeActionKind::Push, || { + MobileHomeStorage::load()?.push(unix_seconds()?, &self.control) + }) + } + + fn run( + &self, + action: MobileHomeActionKind, + operation: impl FnOnce() -> Result, + ) -> Result { + if let Ok(mut current) = self.action.lock() { + *current = Some(action); + } + let reservation = self + .authentication + .reserve_repository_operation(repository_operation(action)) + .map_err(MobileHomeError::from_reservation)?; + let result = operation(); + drop(reservation); + result } } @@ -356,27 +445,50 @@ impl MobileHomeStorage { }) } - fn refresh( + fn commit( + &self, + message: &str, + control: &GitOperationControl, + ) -> Result { + control + .report(GitProgressPhase::Validating) + .map_err(MobileHomeError::from_git)?; + let commit = self + .git + .commit_all(message) + .map_err(MobileHomeError::from_git)?; + control + .report(GitProgressPhase::Refreshing) + .map_err(MobileHomeError::from_git)?; + self.page( + MobileHomeFreshness::Cached, + self.config.mobile_home_refreshed_at(), + Some(commit_notice(&commit)), + ) + .map_err(MobileHomeError::after_commit) + } + + fn fetch( &self, now: i64, control: &GitOperationControl, ) -> Result { let store = self.credentials()?; - let refreshed = self.git.fetch_with_transport_controlled( + let fetched = self.git.fetch_with_transport_controlled( &self.remote, &store, &crate::git::EmbeddedFetchTransport, control, ); let locked = store.lock(); - refreshed.map_err(MobileHomeError::from_git)?; + let outcome = fetched.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { return Err(MobileHomeError::partial_secret(error)); } control .report(GitProgressPhase::Refreshing) .map_err(MobileHomeError::from_git)?; - self.current_page(now, None) + self.current_page(now, Some(fetch_notice(&outcome))) } fn pull( @@ -404,6 +516,31 @@ impl MobileHomeStorage { .map_err(MobileHomeError::after_pull) } + fn push( + &self, + now: i64, + control: &GitOperationControl, + ) -> Result { + let store = self.credentials()?; + let pushed = self.git.push_with_transport_controlled( + &self.remote, + None, + &store, + &crate::git::ReqwestGitTransport, + control, + ); + let locked = store.lock(); + let outcome = pushed.map_err(MobileHomeError::from_git)?; + if let Err(error) = locked { + return Err(MobileHomeError::partial_secret(error)); + } + control + .report(GitProgressPhase::Refreshing) + .map_err(MobileHomeError::from_git)?; + self.current_page(now, Some(push_notice(&outcome))) + .map_err(MobileHomeError::after_push) + } + fn credentials(&self) -> Result { let store = NativeSecretStore::system( SecretCachePolicy::Disabled, @@ -463,6 +600,7 @@ pub enum MobileHomeErrorKind { Authentication, Conflict, DirtyLocalChanges, + NoChanges, Offline, Interrupted, SecureStorage, @@ -557,6 +695,17 @@ impl MobileHomeError { title: "Local Changes Need Attention", detail: "Commit or discard local changes before pulling remote activity.".to_owned(), }, + GitError::NoChanges => Self { + kind: MobileHomeErrorKind::NoChanges, + title: "Nothing to Commit", + detail: "The password-store working tree has no changes to commit.".to_owned(), + }, + GitError::NonFastForward => Self { + kind: MobileHomeErrorKind::Conflict, + title: "Pull Before Pushing", + detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push." + .to_owned(), + }, GitError::NetworkUnavailable => Self { kind: MobileHomeErrorKind::Offline, title: "Server Is Offline", @@ -589,6 +738,43 @@ impl MobileHomeError { } } + fn invalid_commit_message() -> Self { + Self { + kind: MobileHomeErrorKind::NoChanges, + title: "Commit Message Required", + detail: "Enter a non-empty commit message and try again.".to_owned(), + } + } + + fn from_reservation(error: MobileRepositoryOperationError) -> Self { + match error { + MobileRepositoryOperationError::DirtyEditor => Self { + kind: MobileHomeErrorKind::DirtyLocalChanges, + title: "Unsaved Entry Edits", + detail: "Save or discard the open entry editor before pulling or pushing." + .to_owned(), + }, + MobileRepositoryOperationError::OpenEditor => Self { + kind: MobileHomeErrorKind::DirtyLocalChanges, + title: "Close the Entry Editor", + detail: "Close the open entry editor before pulling so its source cannot change underneath it." + .to_owned(), + }, + MobileRepositoryOperationError::Busy => Self { + kind: MobileHomeErrorKind::Interrupted, + title: "Password-Store Action in Progress", + detail: "Wait for the current password-store action to finish, then retry." + .to_owned(), + }, + MobileRepositoryOperationError::Unavailable => Self { + kind: MobileHomeErrorKind::Repository, + title: "Password-Store State Is Unavailable", + detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry." + .to_owned(), + }, + } + } + fn from_secret(error: SecretStoreError) -> Self { let detail = match error { SecretStoreError::Denied => "Access to the application token was denied.", @@ -631,6 +817,24 @@ impl MobileHomeError { .to_owned(), } } + + fn after_commit(_error: Self) -> Self { + Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Commit Completed, Status Unavailable", + detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry." + .to_owned(), + } + } + + fn after_push(_error: Self) -> Self { + Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Push Completed, Status Unavailable", + detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry." + .to_owned(), + } + } } impl fmt::Display for MobileHomeError { @@ -674,6 +878,11 @@ fn page_from_divergence( title: format!("{}/{}", divergence.remote().name(), divergence.branch()), detail: "Tracked HTTPS branch".to_owned(), system_image: "point.3.connected.trianglepath.dotted".to_owned(), + actions: vec![ + mobile_action(MobileHomeActionKind::Fetch), + mobile_action(MobileHomeActionKind::Pull), + mobile_action(MobileHomeActionKind::Push), + ], }, MobileHomeSummaryRow { id: "divergence".to_owned(), @@ -684,6 +893,13 @@ fn page_from_divergence( } else { "arrow.triangle.2.circlepath".to_owned() }, + actions: [ + (behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)), + (ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)), + ] + .into_iter() + .flatten() + .collect(), }, MobileHomeSummaryRow { id: "working-tree".to_owned(), @@ -702,6 +918,11 @@ fn page_from_divergence( } else { "exclamationmark.triangle".to_owned() }, + actions: if local_count > 0 { + vec![mobile_action(MobileHomeActionKind::Commit)] + } else { + Vec::new() + }, }, ]; MobileHomePage { @@ -765,6 +986,11 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm }, timestamp: commit.timestamp(), changes, + actions: vec![mobile_action(if incoming { + MobileHomeActionKind::Pull + } else { + MobileHomeActionKind::Push + })], } } @@ -881,23 +1107,113 @@ fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice { } } -fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress { - let (title, detail) = match phase { - MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."), - MobileHomePhase::Authenticating => ( +fn commit_notice(commit: &str) -> MobileHomeNotice { + MobileHomeNotice { + title: "Changes Committed".to_owned(), + detail: format!("Created commit {}.", &commit[..commit.len().min(12)]), + system_image: "checkmark.circle".to_owned(), + } +} + +fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice { + MobileHomeNotice { + title: if outcome.received_pack() { + "Remote Status Updated" + } else { + "Remote Already Current" + } + .to_owned(), + detail: if outcome.received_pack() { + format!( + "Received new objects from {} without changing the local password store.", + outcome.remote() + ) + } else { + format!("{} has no new objects for this clone.", outcome.remote()) + }, + system_image: "arrow.clockwise.circle".to_owned(), + } +} + +fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice { + let unchanged = outcome.old() == Some(outcome.new_id()); + MobileHomeNotice { + title: if unchanged { + "Remote Already Current" + } else { + "Changes Pushed" + } + .to_owned(), + detail: if unchanged { + format!( + "{}/{} already points to the local commit.", + outcome.remote(), + outcome.branch() + ) + } else { + format!( + "Updated {}/{} with local commits.", + outcome.remote(), + outcome.branch() + ) + }, + system_image: "arrow.up.circle".to_owned(), + } +} + +fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction { + let (title, system_image) = match kind { + MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"), + MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"), + MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"), + MobileHomeActionKind::Push => ("Push", "arrow.up.circle"), + }; + MobileHomeAction { + kind, + title, + system_image, + } +} + +fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation { + match action { + MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit, + MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch, + MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull, + MobileHomeActionKind::Push => MobileRepositoryOperation::Push, + } +} + +fn progress_copy( + action: Option, + phase: MobileHomePhase, +) -> MobileHomeProgress { + let (title, detail) = match (action, phase) { + (Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => ( + "Committing Changes", + "Staging current password-store changes and creating the commit in Rust.", + ), + (_, MobileHomePhase::Validating) => { + ("Checking Home", "Validating local and remote Git state.") + } + (_, MobileHomePhase::Authenticating) => ( "Authenticating", "Reading the application token from protected storage.", ), - MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."), - MobileHomePhase::Integrating => ( + (_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."), + (_, MobileHomePhase::Integrating) => ( "Updating Password Store", "Integrating fetched commits into the local clone.", ), - MobileHomePhase::Finishing => { + (_, MobileHomePhase::Sending) => { + ("Pushing Changes", "Sending local Git objects over HTTPS.") + } + (_, MobileHomePhase::Finishing) => { ("Updating Home", "Building current activity and divergence.") } }; MobileHomeProgress { + action, phase, title: title.to_owned(), detail: detail.to_owned(), @@ -935,8 +1251,8 @@ mod tests { use crate::git::{GitChangeKind, GitError}; use super::{ - MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale, - mobile_change, + MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, + MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy, }; #[test] @@ -974,6 +1290,20 @@ mod tests { assert_eq!(offline.kind(), MobileHomeErrorKind::Offline); let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree); assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges); + let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward); + assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict); + let no_changes = super::MobileHomeError::from_git(GitError::NoChanges); + assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges); assert!(!offline.to_string().contains("token-value")); } + + #[test] + fn actions_and_progress_are_typed_before_swift_presentation() { + let push = mobile_action(MobileHomeActionKind::Push); + assert_eq!(push.title(), "Push"); + assert_eq!(push.system_image(), "arrow.up.circle"); + let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending); + assert_eq!(progress.action(), Some(MobileHomeActionKind::Push)); + assert_eq!(progress.title(), "Pushing Changes"); + } } diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 3e9b555..a5b2340 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -98,6 +98,23 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult { Ok(()) } +#[test] +fn user_commit_stages_every_current_change_in_storage() -> TestResult { + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let git = GitRepository::init(&store, identity())?; + fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?; + fs::write(temporary.path().join("mail.gpg"), b"ciphertext")?; + + let commit = git.commit_all("Save mobile changes.")?; + + assert_eq!(commit.len(), 40); + assert!(git.status()?.is_clean()); + assert_eq!(git.log(Some(1))?[0].message(), "Save mobile changes."); + assert_eq!(git.commit_all("Nothing changed"), Err(GitError::NoChanges)); + Ok(()) +} + #[test] fn nested_repository_selection_is_innermost() -> TestResult { let temporary = tempfile::tempdir()?;