diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 402c61c..aa1fa7e 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -478,6 +478,22 @@ fileprivate struct FfiConverterUInt32: FfiConverterPrimitive { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterInt64: FfiConverterPrimitive { + typealias FfiType = Int64 + typealias SwiftType = Int64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Int64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: Int64, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -551,6 +567,177 @@ fileprivate struct FfiConverterString: FfiConverter { +public protocol MobileHomeOperationProtocol: AnyObject, Sendable { + + func cached() throws -> MobileHomePage + + func cancel() + + func progress() -> MobileHomeProgress + + func pull() throws -> MobileHomePage + + func refresh() throws -> MobileHomePage + + func refreshIfStale() throws -> MobileHomePage + +} +open class MobileHomeOperation: MobileHomeOperationProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_ironstorage_apple_fn_free_mobilehomeoperation(handle, $0) } + } + + + + +open func cached()throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func cancel() {try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + +open func progress() -> MobileHomeProgress { + return try! FfiConverterTypeMobileHomeProgress_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func pull()throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func refresh()throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func refreshIfStale()throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeOperation: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = MobileHomeOperation + + public static func lift(_ handle: UInt64) throws -> MobileHomeOperation { + return MobileHomeOperation(unsafeFromHandle: handle) + } + + public static func lower(_ value: MobileHomeOperation) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeOperation { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: MobileHomeOperation, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeOperation_lift(_ handle: UInt64) throws -> MobileHomeOperation { + return try FfiConverterTypeMobileHomeOperation.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeOperation_lower(_ value: MobileHomeOperation) -> UInt64 { + return FfiConverterTypeMobileHomeOperation.lower(value) +} + + + + + + public protocol MobileOnboardingOperationProtocol: AnyObject, Sendable { func cancel() @@ -700,6 +887,402 @@ public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboa +public struct MobileHomeChange: Equatable, Hashable { + public var id: String + public var title: String + public var detail: String + public var systemImage: String + public var kind: MobileHomeChangeKind + public var status: MobileHomeChangeStatus + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, title: String, detail: String, systemImage: String, kind: MobileHomeChangeKind, status: MobileHomeChangeStatus) { + self.id = id + self.title = title + self.detail = detail + self.systemImage = systemImage + self.kind = kind + self.status = status + } + + + + +} + +#if compiler(>=6) +extension MobileHomeChange: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeChange: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeChange { + return + try MobileHomeChange( + id: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + kind: FfiConverterTypeMobileHomeChangeKind.read(from: &buf), + status: FfiConverterTypeMobileHomeChangeStatus.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeChange, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterTypeMobileHomeChangeKind.write(value.kind, into: &buf) + FfiConverterTypeMobileHomeChangeStatus.write(value.status, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChange_lift(_ buf: RustBuffer) throws -> MobileHomeChange { + return try FfiConverterTypeMobileHomeChange.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChange_lower(_ value: MobileHomeChange) -> RustBuffer { + return FfiConverterTypeMobileHomeChange.lower(value) +} + + +public struct MobileHomeCommit: Equatable, Hashable { + public var id: String + public var title: String + public var detail: String + public var systemImage: String + public var timestamp: Int64 + public var changes: [MobileHomeChange] + + // 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]) { + self.id = id + self.title = title + self.detail = detail + self.systemImage = systemImage + self.timestamp = timestamp + self.changes = changes + } + + + + +} + +#if compiler(>=6) +extension MobileHomeCommit: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeCommit: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeCommit { + return + try MobileHomeCommit( + id: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + timestamp: FfiConverterInt64.read(from: &buf), + changes: FfiConverterSequenceTypeMobileHomeChange.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeCommit, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterInt64.write(value.timestamp, into: &buf) + FfiConverterSequenceTypeMobileHomeChange.write(value.changes, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeCommit_lift(_ buf: RustBuffer) throws -> MobileHomeCommit { + return try FfiConverterTypeMobileHomeCommit.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeCommit_lower(_ value: MobileHomeCommit) -> RustBuffer { + return FfiConverterTypeMobileHomeCommit.lower(value) +} + + +public struct MobileHomeNotice: Equatable, Hashable { + public var title: String + public var detail: String + public var systemImage: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(title: String, detail: String, systemImage: String) { + self.title = title + self.detail = detail + self.systemImage = systemImage + } + + + + +} + +#if compiler(>=6) +extension MobileHomeNotice: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeNotice: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeNotice { + return + try MobileHomeNotice( + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeNotice, into buf: inout [UInt8]) { + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeNotice_lift(_ buf: RustBuffer) throws -> MobileHomeNotice { + return try FfiConverterTypeMobileHomeNotice.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeNotice_lower(_ value: MobileHomeNotice) -> RustBuffer { + return FfiConverterTypeMobileHomeNotice.lower(value) +} + + +public struct MobileHomePage: Equatable, Hashable { + public var freshness: MobileHomeFreshness + public var refreshedAt: Int64? + public var summaries: [MobileHomeSummaryRow] + public var incoming: [MobileHomeCommit] + public var outgoing: [MobileHomeCommit] + public var incomingTotal: UInt32 + public var outgoingTotal: UInt32 + public var notice: MobileHomeNotice? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(freshness: MobileHomeFreshness, refreshedAt: Int64?, summaries: [MobileHomeSummaryRow], incoming: [MobileHomeCommit], outgoing: [MobileHomeCommit], incomingTotal: UInt32, outgoingTotal: UInt32, notice: MobileHomeNotice?) { + self.freshness = freshness + self.refreshedAt = refreshedAt + self.summaries = summaries + self.incoming = incoming + self.outgoing = outgoing + self.incomingTotal = incomingTotal + self.outgoingTotal = outgoingTotal + self.notice = notice + } + + + + +} + +#if compiler(>=6) +extension MobileHomePage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomePage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomePage { + return + try MobileHomePage( + freshness: FfiConverterTypeMobileHomeFreshness.read(from: &buf), + refreshedAt: FfiConverterOptionInt64.read(from: &buf), + summaries: FfiConverterSequenceTypeMobileHomeSummaryRow.read(from: &buf), + incoming: FfiConverterSequenceTypeMobileHomeCommit.read(from: &buf), + outgoing: FfiConverterSequenceTypeMobileHomeCommit.read(from: &buf), + incomingTotal: FfiConverterUInt32.read(from: &buf), + outgoingTotal: FfiConverterUInt32.read(from: &buf), + notice: FfiConverterOptionTypeMobileHomeNotice.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomePage, into buf: inout [UInt8]) { + FfiConverterTypeMobileHomeFreshness.write(value.freshness, into: &buf) + FfiConverterOptionInt64.write(value.refreshedAt, into: &buf) + FfiConverterSequenceTypeMobileHomeSummaryRow.write(value.summaries, into: &buf) + FfiConverterSequenceTypeMobileHomeCommit.write(value.incoming, into: &buf) + FfiConverterSequenceTypeMobileHomeCommit.write(value.outgoing, into: &buf) + FfiConverterUInt32.write(value.incomingTotal, into: &buf) + FfiConverterUInt32.write(value.outgoingTotal, into: &buf) + FfiConverterOptionTypeMobileHomeNotice.write(value.notice, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomePage_lift(_ buf: RustBuffer) throws -> MobileHomePage { + return try FfiConverterTypeMobileHomePage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomePage_lower(_ value: MobileHomePage) -> RustBuffer { + return FfiConverterTypeMobileHomePage.lower(value) +} + + +public struct MobileHomeProgress: Equatable, Hashable { + 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) { + self.phase = phase + self.title = title + self.detail = detail + } + + + + +} + +#if compiler(>=6) +extension MobileHomeProgress: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeProgress: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeProgress { + return + try MobileHomeProgress( + phase: FfiConverterTypeMobileHomePhase.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeProgress, into buf: inout [UInt8]) { + FfiConverterTypeMobileHomePhase.write(value.phase, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeProgress_lift(_ buf: RustBuffer) throws -> MobileHomeProgress { + return try FfiConverterTypeMobileHomeProgress.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeProgress_lower(_ value: MobileHomeProgress) -> RustBuffer { + return FfiConverterTypeMobileHomeProgress.lower(value) +} + + +public struct MobileHomeSummaryRow: Equatable, Hashable { + public var id: String + public var title: String + public var detail: String + public var systemImage: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, title: String, detail: String, systemImage: String) { + self.id = id + self.title = title + self.detail = detail + self.systemImage = systemImage + } + + + + +} + +#if compiler(>=6) +extension MobileHomeSummaryRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeSummaryRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeSummaryRow { + return + try MobileHomeSummaryRow( + id: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileHomeSummaryRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeSummaryRow_lift(_ buf: RustBuffer) throws -> MobileHomeSummaryRow { + return try FfiConverterTypeMobileHomeSummaryRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeSummaryRow_lower(_ value: MobileHomeSummaryRow) -> RustBuffer { + return FfiConverterTypeMobileHomeSummaryRow.lower(value) +} + + public struct MobileOnboardingDiscovery: Equatable, Hashable { public var branches: [String] public var selectedBranch: UInt32 @@ -995,6 +1578,520 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe +public enum MobileHomeChangeKind: Equatable, Hashable { + + case passwordEntry + case recipientPolicy + case recipientSignature + case repositoryFile + + + + + +} + +#if compiler(>=6) +extension MobileHomeChangeKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeChangeKind: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeChangeKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeChangeKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .passwordEntry + + case 2: return .recipientPolicy + + case 3: return .recipientSignature + + case 4: return .repositoryFile + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeChangeKind, into buf: inout [UInt8]) { + switch value { + + + case .passwordEntry: + writeInt(&buf, Int32(1)) + + + case .recipientPolicy: + writeInt(&buf, Int32(2)) + + + case .recipientSignature: + writeInt(&buf, Int32(3)) + + + case .repositoryFile: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChangeKind_lift(_ buf: RustBuffer) throws -> MobileHomeChangeKind { + return try FfiConverterTypeMobileHomeChangeKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChangeKind_lower(_ value: MobileHomeChangeKind) -> RustBuffer { + return FfiConverterTypeMobileHomeChangeKind.lower(value) +} + + + + +public enum MobileHomeChangeStatus: Equatable, Hashable { + + case added + case modified + case deleted + + + + + +} + +#if compiler(>=6) +extension MobileHomeChangeStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeChangeStatus: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeChangeStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeChangeStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .added + + case 2: return .modified + + case 3: return .deleted + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeChangeStatus, into buf: inout [UInt8]) { + switch value { + + + case .added: + writeInt(&buf, Int32(1)) + + + case .modified: + writeInt(&buf, Int32(2)) + + + case .deleted: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChangeStatus_lift(_ buf: RustBuffer) throws -> MobileHomeChangeStatus { + return try FfiConverterTypeMobileHomeChangeStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeChangeStatus_lower(_ value: MobileHomeChangeStatus) -> RustBuffer { + return FfiConverterTypeMobileHomeChangeStatus.lower(value) +} + + + + +public enum MobileHomeErrorKind: Equatable, Hashable { + + case missingConfiguration + case configuration + case authentication + case conflict + case dirtyLocalChanges + case offline + case interrupted + case secureStorage + case repository + case partialProgress + + + + + +} + +#if compiler(>=6) +extension MobileHomeErrorKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeErrorKind: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeErrorKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeErrorKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .missingConfiguration + + case 2: return .configuration + + case 3: return .authentication + + case 4: return .conflict + + case 5: return .dirtyLocalChanges + + case 6: return .offline + + case 7: return .interrupted + + case 8: return .secureStorage + + case 9: return .repository + + case 10: return .partialProgress + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeErrorKind, into buf: inout [UInt8]) { + switch value { + + + case .missingConfiguration: + writeInt(&buf, Int32(1)) + + + case .configuration: + writeInt(&buf, Int32(2)) + + + case .authentication: + writeInt(&buf, Int32(3)) + + + case .conflict: + writeInt(&buf, Int32(4)) + + + case .dirtyLocalChanges: + writeInt(&buf, Int32(5)) + + + case .offline: + writeInt(&buf, Int32(6)) + + + case .interrupted: + writeInt(&buf, Int32(7)) + + + case .secureStorage: + writeInt(&buf, Int32(8)) + + + case .repository: + writeInt(&buf, Int32(9)) + + + case .partialProgress: + writeInt(&buf, Int32(10)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeErrorKind_lift(_ buf: RustBuffer) throws -> MobileHomeErrorKind { + return try FfiConverterTypeMobileHomeErrorKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeErrorKind_lower(_ value: MobileHomeErrorKind) -> RustBuffer { + return FfiConverterTypeMobileHomeErrorKind.lower(value) +} + + + +public +enum MobileHomeFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Failed(kind: MobileHomeErrorKind, title: String, detail: String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension MobileHomeFfiError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeFfiError: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeFfiError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeFfiError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Failed( + kind: try FfiConverterTypeMobileHomeErrorKind.read(from: &buf), + title: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeFfiError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Failed(kind,title,detail): + writeInt(&buf, Int32(1)) + FfiConverterTypeMobileHomeErrorKind.write(kind, into: &buf) + FfiConverterString.write(title, into: &buf) + FfiConverterString.write(detail, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeFfiError_lift(_ buf: RustBuffer) throws -> MobileHomeFfiError { + return try FfiConverterTypeMobileHomeFfiError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeFfiError_lower(_ value: MobileHomeFfiError) -> RustBuffer { + return FfiConverterTypeMobileHomeFfiError.lower(value) +} + + + +public enum MobileHomeFreshness: Equatable, Hashable { + + case neverRefreshed + case cached + case current + + + + + +} + +#if compiler(>=6) +extension MobileHomeFreshness: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomeFreshness: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeFreshness + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomeFreshness { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .neverRefreshed + + case 2: return .cached + + case 3: return .current + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomeFreshness, into buf: inout [UInt8]) { + switch value { + + + case .neverRefreshed: + writeInt(&buf, Int32(1)) + + + case .cached: + writeInt(&buf, Int32(2)) + + + case .current: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeFreshness_lift(_ buf: RustBuffer) throws -> MobileHomeFreshness { + return try FfiConverterTypeMobileHomeFreshness.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomeFreshness_lower(_ value: MobileHomeFreshness) -> RustBuffer { + return FfiConverterTypeMobileHomeFreshness.lower(value) +} + + + + +public enum MobileHomePhase: Equatable, Hashable { + + case validating + case authenticating + case receiving + case integrating + case finishing + + + + + +} + +#if compiler(>=6) +extension MobileHomePhase: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileHomePhase: FfiConverterRustBuffer { + typealias SwiftType = MobileHomePhase + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomePhase { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .validating + + case 2: return .authenticating + + case 3: return .receiving + + case 4: return .integrating + + case 5: return .finishing + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileHomePhase, into buf: inout [UInt8]) { + switch value { + + + case .validating: + writeInt(&buf, Int32(1)) + + + case .authenticating: + writeInt(&buf, Int32(2)) + + + case .receiving: + writeInt(&buf, Int32(3)) + + + case .integrating: + writeInt(&buf, Int32(4)) + + + case .finishing: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomePhase_lift(_ buf: RustBuffer) throws -> MobileHomePhase { + return try FfiConverterTypeMobileHomePhase.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileHomePhase_lower(_ value: MobileHomePhase) -> RustBuffer { + return FfiConverterTypeMobileHomePhase.lower(value) +} + + + + public enum MobileOnboardingErrorKind: Equatable, Hashable { case invalidInput @@ -1516,6 +2613,54 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer { } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer { + typealias SwiftType = Int64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterInt64.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 FfiConverterInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeMobileHomeNotice: FfiConverterRustBuffer { + typealias SwiftType = MobileHomeNotice? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeMobileHomeNotice.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 FfiConverterTypeMobileHomeNotice.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -1541,6 +2686,81 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileHomeChange: FfiConverterRustBuffer { + typealias SwiftType = [MobileHomeChange] + + public static func write(_ value: [MobileHomeChange], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileHomeChange.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileHomeChange] { + let len: Int32 = try readInt(&buf) + var seq = [MobileHomeChange]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileHomeChange.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileHomeCommit: FfiConverterRustBuffer { + typealias SwiftType = [MobileHomeCommit] + + public static func write(_ value: [MobileHomeCommit], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileHomeCommit.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileHomeCommit] { + let len: Int32 = try readInt(&buf) + var seq = [MobileHomeCommit]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileHomeCommit.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileHomeSummaryRow: FfiConverterRustBuffer { + typealias SwiftType = [MobileHomeSummaryRow] + + public static func write(_ value: [MobileHomeSummaryRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileHomeSummaryRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileHomeSummaryRow] { + let len: Int32 = try readInt(&buf) + var seq = [MobileHomeSummaryRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileHomeSummaryRow.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -1565,6 +2785,13 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer { return seq } } +public func mobileHomeOperation() -> MobileHomeOperation { + return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_mobile_home_operation(uniffiCallStatus + ) +}) +} public func mobileOnboardingOperation(serverUrl: String, account: String, repositoryPath: String, applicationToken: String)throws -> MobileOnboardingOperation { return try FfiConverterTypeMobileOnboardingOperation_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) { uniffiCallStatus in @@ -1629,6 +2856,9 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) { return InitializationResult.apiChecksumMismatch } @@ -1647,6 +2877,24 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel() != 13921) { + 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) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale() != 27818) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel() != 49755) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 84ca700..25d87d0 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -242,6 +242,46 @@ typedef struct UniffiForeignFutureResultVoid { typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid ); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION +uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEHOMEOPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEHOMEOPERATION +void uniffi_ironstorage_apple_fn_free_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CACHED +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CACHED +RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CANCEL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CANCEL +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_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 +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PULL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PULL +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 +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE +RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status +); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION @@ -271,6 +311,12 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status +); +#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 + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION @@ -563,6 +609,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION +uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION @@ -599,6 +651,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL +#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_PROGRESS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_progress(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PULL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PULL +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 + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE +uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index e0bf98f..5bad480 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -1,5 +1,11 @@ import UIKit +extension Notification.Name { + static let ironStorageLocalStoreDidChange = Notification.Name( + "de.rfc1437.ironstorage.local-store-did-change" + ) +} + @main final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? @@ -85,6 +91,26 @@ private final class ShellViewController: UITableViewController { private var page: MobilePage private var loadTask: Task? private var loadGeneration = 0 + private var homePage: MobileHomePage? + private var homeTask: Task? + private var homeProgressTask: Task? + private var homeOperation: MobileHomeOperation? + private var homeGeneration = 0 + private var isHomeWorking = false + + private enum HomeSection { + case summary + case incoming + case outgoing + case empty + case notice + } + + private enum HomeRequest: Equatable { + case refreshIfStale + case refresh + case pull + } init(page: MobilePage) { shellTab = page.tab @@ -94,6 +120,12 @@ private final class ShellViewController: UITableViewController { navigationItem.largeTitleDisplayMode = .always refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageLocalStoreDidChange, + object: nil + ) } @available(*, unavailable) @@ -103,6 +135,10 @@ private final class ShellViewController: UITableViewController { deinit { loadTask?.cancel() + homeOperation?.cancel() + homeTask?.cancel() + homeProgressTask?.cancel() + NotificationCenter.default.removeObserver(self) } override func viewDidLoad() { @@ -113,25 +149,73 @@ private final class ShellViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadShell() + if shellTab == .home, page.state == .ready, homePage != nil { + runHome(.refreshIfStale) + } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) loadTask?.cancel() + cancelHomeWork() } override func numberOfSections(in tableView: UITableView) -> Int { - page.state == .ready ? 1 : 0 + guard page.state == .ready else { return 0 } + if shellTab == .home { + return homeSections.count + } + return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 1 + guard shellTab == .home else { return 1 } + switch homeSections[section] { + case .summary: return homePage?.summaries.count ?? 0 + case .incoming: return homePage?.incoming.count ?? 0 + case .outgoing: return homePage?.outgoing.count ?? 0 + case .empty, .notice: return 1 + } + } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + guard shellTab == .home else { return nil } + switch homeSections[section] { + case .summary: return "Status" + case .incoming: return "Incoming Activity" + case .outgoing: return "Outgoing Activity" + case .empty: return "Remote Activity" + case .notice: return "Result" + } + } + + override func tableView( + _ tableView: UITableView, + titleForFooterInSection section: Int + ) -> String? { + guard shellTab == .home, let homePage else { return nil } + switch homeSections[section] { + case .summary: + return freshnessDescription(homePage) + case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count): + return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits." + case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count): + return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits." + default: + return nil + } } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { + if shellTab == .home { + return homeCell(at: indexPath) + } let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: page.systemImage) @@ -144,7 +228,42 @@ private final class ShellViewController: UITableViewController { return cell } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard shellTab == .home, let homePage else { return } + let section = homeSections[indexPath.section] + let commit: MobileHomeCommit + let incoming: Bool + switch section { + case .incoming: + commit = homePage.incoming[indexPath.row] + incoming = true + case .outgoing: + commit = homePage.outgoing[indexPath.row] + incoming = false + default: + return + } + navigationController?.pushViewController( + MobileCommitActivityViewController(commit: commit, incoming: incoming), + animated: true + ) + } + @objc private func refreshRequested() { + if shellTab == .home, page.state == .ready { + guard !isHomeWorking else { + refreshControl?.endRefreshing() + return + } + runHome(.pull) + } else { + reloadShell() + } + } + + @objc private func localStoreDidChange() { + guard shellTab != .home else { return } reloadShell() } @@ -174,6 +293,15 @@ private final class ShellViewController: UITableViewController { target: self, action: #selector(setupRequested) ) + } else if page.state == .ready, shellTab == .home { + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "arrow.clockwise"), + style: .plain, + target: self, + action: #selector(statusRefreshRequested) + ) + navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status" + navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking } else if page.state == .ready, shellTab == .preferences { navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Update Token", @@ -189,6 +317,9 @@ private final class ShellViewController: UITableViewController { guard page.state != .ready else { contentUnavailableConfiguration = nil + if shellTab == .home { + loadHomeIfNeeded() + } return } var configuration = page.state == .loading @@ -208,6 +339,263 @@ private final class ShellViewController: UITableViewController { navigationController?.pushViewController(TokenUpdateViewController(), animated: true) } + @objc private func statusRefreshRequested() { + runHome(.refresh) + } + + private var homeSections: [HomeSection] { + guard let homePage else { return [] } + var sections: [HomeSection] = [.summary] + if !homePage.incoming.isEmpty { + sections.append(.incoming) + } + if !homePage.outgoing.isEmpty { + sections.append(.outgoing) + } + if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 { + sections.append(.empty) + } + if homePage.notice != nil { + sections.append(.notice) + } + return sections + } + + private func homeCell(at indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + guard let homePage else { return cell } + var content = cell.defaultContentConfiguration() + switch homeSections[indexPath.section] { + case .summary: + let row = homePage.summaries[indexPath.row] + content.image = UIImage(systemName: row.systemImage) + content.text = row.title + content.secondaryText = row.detail + cell.selectionStyle = .none + case .incoming: + configureCommit( + homePage.incoming[indexPath.row], + content: &content, + cell: cell + ) + case .outgoing: + configureCommit( + homePage.outgoing[indexPath.row], + content: &content, + cell: cell + ) + case .empty: + content.image = UIImage(systemName: "checkmark.circle") + content.text = "No Remote Activity" + content.secondaryText = "There are no commits to pull or push." + cell.selectionStyle = .none + case .notice: + if let notice = homePage.notice { + content.image = UIImage(systemName: notice.systemImage) + content.text = notice.title + content.secondaryText = notice.detail + } + content.secondaryTextProperties.numberOfLines = 0 + cell.selectionStyle = .none + } + content.secondaryTextProperties.numberOfLines = 0 + cell.contentConfiguration = content + return cell + } + + private func configureCommit( + _ commit: MobileHomeCommit, + content: inout UIListContentConfiguration, + cell: UITableViewCell + ) { + content.image = UIImage(systemName: commit.systemImage) + content.text = commit.title + content.secondaryText = "\(commit.detail)\n\(formattedDate(commit.timestamp))" + content.secondaryTextProperties.numberOfLines = 0 + cell.accessoryType = .disclosureIndicator + cell.accessibilityHint = "Shows password-store changes in this commit." + } + + private func freshnessDescription(_ page: MobileHomePage) -> String { + let refreshed = page.refreshedAt.map(formattedDate) + switch page.freshness { + case .neverRefreshed: + return "Not refreshed. Pull down to update the local clone or tap Refresh Status." + case .cached: + return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status." + case .current: + return "Current · Refreshed \(refreshed ?? "just now")." + } + } + + private func formattedDate(_ timestamp: Int64) -> String { + Date(timeIntervalSince1970: TimeInterval(timestamp)).formatted( + date: .abbreviated, + time: .shortened + ) + } + + private func loadHomeIfNeeded() { + guard homePage == nil, homeTask == nil, !isHomeWorking else { return } + homeGeneration += 1 + let current = homeGeneration + let operation = mobileHomeOperation() + homeOperation = operation + isHomeWorking = true + navigationItem.rightBarButtonItem?.isEnabled = false + showHomeLoading() + homeTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success(try operation.cached()) + } catch let error as MobileHomeFfiError { + return .failure(HomeFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard let self, current == homeGeneration else { return } + finishHome(result, request: nil) + if case .success = result { + runHome(.refreshIfStale) + } + } + } + + private func runHome(_ request: HomeRequest) { + guard page.state == .ready, shellTab == .home, !isHomeWorking else { return } + cancelHomeWork() + homeGeneration += 1 + let current = homeGeneration + let operation = mobileHomeOperation() + homeOperation = operation + isHomeWorking = true + navigationItem.rightBarButtonItem?.isEnabled = false + showHomeProgress(operation.progress()) + homeProgressTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .milliseconds(150)) + } catch { + return + } + guard !Task.isCancelled, let self, current == homeGeneration else { return } + showHomeProgress(operation.progress()) + } + } + homeTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + let page: MobileHomePage = switch request { + case .refreshIfStale: try operation.refreshIfStale() + case .refresh: try operation.refresh() + case .pull: try operation.pull() + } + return Result.success(page) + } catch let error as MobileHomeFfiError { + return .failure(HomeFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard let self, current == homeGeneration else { return } + finishHome(result, request: request) + } + } + + private func finishHome( + _ result: Result, + request: HomeRequest? + ) { + homeProgressTask?.cancel() + homeProgressTask = nil + homeTask = nil + homeOperation = nil + isHomeWorking = false + navigationItem.titleView = nil + navigationItem.prompt = nil + navigationItem.rightBarButtonItem?.isEnabled = true + refreshControl?.endRefreshing() + switch result { + case let .success(homePage): + self.homePage = homePage + contentUnavailableConfiguration = nil + tableView.reloadData() + if request == .pull { + NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, 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) + } + } + } + + private func showHomeLoading() { + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = "Loading Activity" + configuration.secondaryText = "Reading cached remote-branch state from storage." + contentUnavailableConfiguration = configuration + } + + private func showHomeFailure(_ failure: HomeFailure) { + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "exclamationmark.triangle") + configuration.text = failure.title + configuration.secondaryText = failure.detail + contentUnavailableConfiguration = configuration + } + + private func presentHomeFailure(_ failure: HomeFailure) { + let alert = UIAlertController( + title: failure.title, + message: failure.detail, + preferredStyle: .alert + ) + if failure.kind == .authentication || failure.kind == .secureStorage { + alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in + self?.tabBarController?.selectedIndex = 3 + }) + } + alert.addAction(UIAlertAction(title: "OK", style: .cancel)) + present(alert, animated: true) + } + + private func showHomeProgress(_ progress: MobileHomeProgress) { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + spinner.accessibilityLabel = "In progress" + let label = UILabel() + label.text = progress.title + label.font = .preferredFont(forTextStyle: .headline) + label.adjustsFontForContentSizeCategory = true + let stack = UIStackView(arrangedSubviews: [spinner, label]) + stack.spacing = 8 + navigationItem.titleView = stack + navigationItem.prompt = progress.detail + } + + private func cancelHomeWork() { + homeGeneration += 1 + homeOperation?.cancel() + homeOperation = nil + homeTask?.cancel() + homeTask = nil + homeProgressTask?.cancel() + homeProgressTask = nil + isHomeWorking = false + navigationItem.titleView = nil + navigationItem.prompt = nil + refreshControl?.endRefreshing() + navigationItem.rightBarButtonItem?.isEnabled = true + } + private func stateImage(_ state: MobileShellState) -> String { switch state { case .loading: "hourglass" @@ -219,6 +607,96 @@ private final class ShellViewController: UITableViewController { } } +private struct HomeFailure: Error, Sendable { + let kind: MobileHomeErrorKind + let title: String + let detail: String + + init(_ error: MobileHomeFfiError) { + switch error { + case let .Failed(kind, title, detail): + self.kind = kind + self.title = title + self.detail = detail + } + } + + static let unexpected = HomeFailure( + kind: .repository, + title: "Remote Activity Failed", + detail: "IronStorage could not load the storage-provided Home page." + ) + + private init(kind: MobileHomeErrorKind, title: String, detail: String) { + self.kind = kind + self.title = title + self.detail = detail + } +} + +@MainActor +private final class MobileCommitActivityViewController: UITableViewController { + private let commit: MobileHomeCommit + + init(commit: MobileHomeCommit, incoming: Bool) { + self.commit = commit + super.init(style: .insetGrouped) + title = incoming ? "Incoming Commit" : "Outgoing Commit" + navigationItem.largeTitleDisplayMode = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 1 : max(commit.changes.count, 1) + } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + section == 0 ? "Commit" : "Password Store Changes" + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + if indexPath.section == 0 { + content.image = UIImage(systemName: commit.systemImage) + content.text = commit.title + content.secondaryText = "\(commit.detail)\n\(formattedDate)" + } else if commit.changes.isEmpty { + content.image = UIImage(systemName: "minus.circle") + content.text = "No File Changes" + content.secondaryText = "This commit does not change password-store files." + } else { + let change = commit.changes[indexPath.row] + content.image = UIImage(systemName: change.systemImage) + content.text = change.title + content.secondaryText = change.detail + } + content.secondaryTextProperties.numberOfLines = 0 + cell.contentConfiguration = content + cell.selectionStyle = .none + return cell + } + + private var formattedDate: String { + Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted( + date: .abbreviated, + time: .shortened + ) + } +} + @MainActor private final class TokenUpdateViewController: UITableViewController { private let accountField = UITextField() diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index dbc0312..ed81625 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -8,6 +8,12 @@ use std::{error::Error, fmt, sync::Arc}; use ironstorage::{ config::ConfigError, mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, + mobile_home::{ + self, MobileHomeChangeKind as StorageHomeChangeKind, + MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError, + MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness, + MobileHomePhase as StorageHomePhase, + }, mobile_onboarding::{ self, MobileOnboardingError as StorageOnboardingError, MobileOnboardingErrorKind as StorageOnboardingErrorKind, @@ -97,6 +103,284 @@ pub struct MobileShell { pub pages: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomeFreshness { + NeverRefreshed, + Cached, + Current, +} + +impl From for MobileHomeFreshness { + fn from(freshness: StorageHomeFreshness) -> Self { + match freshness { + StorageHomeFreshness::NeverRefreshed => Self::NeverRefreshed, + StorageHomeFreshness::Cached => Self::Cached, + StorageHomeFreshness::Current => Self::Current, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomeChangeKind { + PasswordEntry, + RecipientPolicy, + RecipientSignature, + RepositoryFile, +} + +impl From for MobileHomeChangeKind { + fn from(kind: StorageHomeChangeKind) -> Self { + match kind { + StorageHomeChangeKind::PasswordEntry => Self::PasswordEntry, + StorageHomeChangeKind::RecipientPolicy => Self::RecipientPolicy, + StorageHomeChangeKind::RecipientSignature => Self::RecipientSignature, + StorageHomeChangeKind::RepositoryFile => Self::RepositoryFile, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomeChangeStatus { + Added, + Modified, + Deleted, +} + +impl From for MobileHomeChangeStatus { + fn from(status: StorageHomeChangeStatus) -> Self { + match status { + StorageHomeChangeStatus::Added => Self::Added, + StorageHomeChangeStatus::Modified => Self::Modified, + StorageHomeChangeStatus::Deleted => Self::Deleted, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeSummaryRow { + pub id: String, + pub title: String, + pub detail: String, + pub system_image: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeChange { + pub id: String, + pub title: String, + pub detail: String, + pub system_image: String, + pub kind: MobileHomeChangeKind, + pub status: MobileHomeChangeStatus, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeCommit { + pub id: String, + pub title: String, + pub detail: String, + pub system_image: String, + pub timestamp: i64, + pub changes: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeNotice { + pub title: String, + pub detail: String, + pub system_image: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomePage { + pub freshness: MobileHomeFreshness, + pub refreshed_at: Option, + pub summaries: Vec, + pub incoming: Vec, + pub outgoing: Vec, + pub incoming_total: u32, + pub outgoing_total: u32, + pub notice: Option, +} + +impl From for MobileHomePage { + fn from(page: mobile_home::MobileHomePage) -> Self { + Self { + freshness: page.freshness().into(), + refreshed_at: page.refreshed_at(), + summaries: page + .summaries() + .iter() + .map(|row| MobileHomeSummaryRow { + id: row.id().to_owned(), + title: row.title().to_owned(), + detail: row.detail().to_owned(), + system_image: row.system_image().to_owned(), + }) + .collect(), + incoming: page.incoming().iter().map(mobile_home_commit).collect(), + outgoing: page.outgoing().iter().map(mobile_home_commit).collect(), + incoming_total: u32::try_from(page.incoming_total()).unwrap_or(u32::MAX), + outgoing_total: u32::try_from(page.outgoing_total()).unwrap_or(u32::MAX), + notice: page.notice().map(|notice| MobileHomeNotice { + title: notice.title().to_owned(), + detail: notice.detail().to_owned(), + system_image: notice.system_image().to_owned(), + }), + } + } +} + +fn mobile_home_commit(commit: &mobile_home::MobileHomeCommit) -> MobileHomeCommit { + MobileHomeCommit { + id: commit.id().to_owned(), + title: commit.title().to_owned(), + detail: commit.detail().to_owned(), + system_image: commit.system_image().to_owned(), + timestamp: commit.timestamp(), + changes: commit + .changes() + .iter() + .map(|change| MobileHomeChange { + id: change.id().to_owned(), + title: change.title().to_owned(), + detail: change.detail().to_owned(), + system_image: change.system_image().to_owned(), + kind: change.kind().into(), + status: change.status().into(), + }) + .collect(), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomePhase { + Validating, + Authenticating, + Receiving, + Integrating, + Finishing, +} + +impl From for MobileHomePhase { + fn from(phase: StorageHomePhase) -> Self { + match phase { + StorageHomePhase::Validating => Self::Validating, + StorageHomePhase::Authenticating => Self::Authenticating, + StorageHomePhase::Receiving => Self::Receiving, + StorageHomePhase::Integrating => Self::Integrating, + StorageHomePhase::Finishing => Self::Finishing, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileHomeProgress { + pub phase: MobileHomePhase, + pub title: String, + pub detail: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileHomeErrorKind { + MissingConfiguration, + Configuration, + Authentication, + Conflict, + DirtyLocalChanges, + Offline, + Interrupted, + SecureStorage, + Repository, + PartialProgress, +} + +impl From for MobileHomeErrorKind { + fn from(kind: StorageHomeErrorKind) -> Self { + match kind { + StorageHomeErrorKind::MissingConfiguration => Self::MissingConfiguration, + StorageHomeErrorKind::Configuration => Self::Configuration, + StorageHomeErrorKind::Authentication => Self::Authentication, + StorageHomeErrorKind::Conflict => Self::Conflict, + StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges, + StorageHomeErrorKind::Offline => Self::Offline, + StorageHomeErrorKind::Interrupted => Self::Interrupted, + StorageHomeErrorKind::SecureStorage => Self::SecureStorage, + StorageHomeErrorKind::Repository => Self::Repository, + StorageHomeErrorKind::PartialProgress => Self::PartialProgress, + } + } +} + +#[derive(Debug, uniffi::Error)] +pub enum MobileHomeFfiError { + Failed { + kind: MobileHomeErrorKind, + title: String, + detail: String, + }, +} + +impl fmt::Display for MobileHomeFfiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"), + } + } +} + +impl Error for MobileHomeFfiError {} + +impl From for MobileHomeFfiError { + fn from(error: StorageHomeError) -> Self { + Self::Failed { + kind: error.kind().into(), + title: error.title().to_owned(), + detail: error.detail().to_owned(), + } + } +} + +#[derive(uniffi::Object)] +pub struct MobileHomeOperation { + operation: mobile_home::MobileHomeOperation, +} + +#[uniffi::export] +impl MobileHomeOperation { + pub fn progress(&self) -> MobileHomeProgress { + let progress = self.operation.progress(); + MobileHomeProgress { + phase: progress.phase().into(), + title: progress.title().to_owned(), + detail: progress.detail().to_owned(), + } + } + + pub fn cached(&self) -> Result { + self.operation.cached().map(Into::into).map_err(Into::into) + } + + pub fn refresh_if_stale(&self) -> Result { + self.operation + .refresh_if_stale() + .map(Into::into) + .map_err(Into::into) + } + + pub fn refresh(&self) -> Result { + self.operation.refresh().map(Into::into).map_err(Into::into) + } + + pub fn pull(&self) -> Result { + self.operation.pull().map(Into::into).map_err(Into::into) + } + + pub fn cancel(&self) { + self.operation.cancel(); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileOnboardingPhase { Validating, @@ -301,6 +585,13 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr mobile::store_selected_tab(tab.into()).map_err(Into::into) } +#[uniffi::export] +pub fn mobile_home_operation() -> Arc { + Arc::new(MobileHomeOperation { + operation: mobile_home::MobileHomeOperation::default(), + }) +} + #[uniffi::export] pub fn mobile_onboarding_operation( server_url: String, @@ -333,7 +624,10 @@ pub fn replace_configured_mobile_application_token( #[cfg(test)] mod tests { - use super::{MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileTab}; + use super::{ + MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, + MobileTab, + }; #[test] fn bridge_reads_product_name_from_storage_crate() { @@ -374,4 +668,12 @@ 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); + } } diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 929ec34..5a675aa 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -36,6 +36,7 @@ pub struct Config { clipboard_timeout: ClipboardTimeout, authentication_timeout: AuthenticationTimeout, mobile_tab: MobileTab, + mobile_home_refreshed_at: Option, git_remotes: Vec, } @@ -125,6 +126,10 @@ impl Config { self.mobile_tab } + pub fn mobile_home_refreshed_at(&self) -> Option { + self.mobile_home_refreshed_at + } + pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } @@ -168,6 +173,36 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> { + if unix_seconds <= 0 { + return Err(ConfigError::InvalidField { + field: "ui.home_remote_refreshed_at_unix_seconds", + }); + } + let mut document = self.document.clone(); + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let ui = root + .entry("ui") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "ui" })?; + ui.insert( + "home_remote_refreshed_at_unix_seconds".to_owned(), + toml::Value::Integer(unix_seconds), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + pub(crate) fn create_mobile_clone( source: PathBuf, vault: &Path, @@ -806,6 +841,7 @@ struct RawSecurity { #[serde(deny_unknown_fields)] struct RawUi { selected_mobile_tab: Option, + home_remote_refreshed_at_unix_seconds: Option, } #[derive(Deserialize)] @@ -887,6 +923,15 @@ fn validate_config( .map(MobileTab::from_config) .transpose()? .unwrap_or_default(); + let mobile_home_refreshed_at = match raw.ui.home_remote_refreshed_at_unix_seconds { + Some(value) if value > 0 => Some(value), + Some(_) => { + return Err(ConfigError::InvalidField { + field: "ui.home_remote_refreshed_at_unix_seconds", + }); + } + None => None, + }; let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { @@ -899,6 +944,7 @@ fn validate_config( clipboard_timeout, authentication_timeout, mobile_tab, + mobile_home_refreshed_at, git_remotes, }) } @@ -1073,7 +1119,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; - validate_table(ui, "ui", &["selected_mobile_tab"])?; + validate_table( + ui, + "ui", + &[ + "selected_mobile_tab", + "home_remote_refreshed_at_unix_seconds", + ], + )?; } let Some(git) = root.get("git") else { return Ok(()); @@ -1215,6 +1268,8 @@ fn native_config_directory() -> Option { mod tests { use std::fs; + use crate::mobile::MobileTab; + use super::{Config, ConfigError, GitRemote}; #[test] @@ -1234,8 +1289,16 @@ mod tests { "repository-example", ) .expect("remote"); - Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote) + let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote) .expect("create config"); + assert_eq!(config.mobile_home_refreshed_at(), None); + config + .update_mobile_home_refresh(1_789_000_000) + .expect("persist refresh time"); + Config::load(Some(&source)) + .expect("reload timestamped config") + .update_mobile_tab(MobileTab::Preferences) + .expect("persist tab after refresh time"); let contents = fs::read_to_string(&source).expect("read config"); assert!(!contents.contains("token =")); assert!(!contents.contains("password =")); @@ -1251,12 +1314,15 @@ mod tests { } ); fs::rename(&original, &relocated).expect("relocate app container"); + let relocated_config = + Config::load(Some(&relocated.join("config.toml"))).expect("reload config"); assert_eq!( - Config::load(Some(&relocated.join("config.toml"))) - .expect("reload config") - .default_key() - .as_str(), - "ALICE" + ( + relocated_config.default_key().as_str(), + relocated_config.mobile_home_refreshed_at(), + relocated_config.mobile_tab(), + ), + ("ALICE", Some(1_789_000_000), MobileTab::Preferences) ); } } diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index b6df1b8..50479f1 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -180,6 +180,53 @@ pub struct GitSnapshot { recent: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitCommitActivity { + commit: GitLogEntry, + changes: Vec, +} + +impl GitCommitActivity { + pub fn commit(&self) -> &GitLogEntry { + &self.commit + } + + pub fn changes(&self) -> &[GitChange] { + &self.changes + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitDivergence { + branch: String, + status: GitStatus, + remote: GitRemoteStatus, + incoming: Vec, + outgoing: Vec, +} + +impl GitDivergence { + pub fn branch(&self) -> &str { + &self.branch + } + + pub fn status(&self) -> &GitStatus { + &self.status + } + + pub fn remote(&self) -> &GitRemoteStatus { + &self.remote + } + + pub fn incoming(&self) -> &[GitCommitActivity] { + &self.incoming + } + + pub fn outgoing(&self) -> &[GitCommitActivity] { + &self.outgoing + } +} + impl GitSnapshot { pub fn root(&self) -> &Path { &self.root @@ -1698,32 +1745,144 @@ impl GitRepository { }) } + /// Return commits and typed path changes on each side of the configured + /// remote-tracking branch without performing network access. + pub fn divergence( + &self, + configured: &GitRemote, + activity_limit: usize, + ) -> Result { + let branch = self.current_branch()?; + let status = self.status()?; + let name = configured.name().as_str(); + let actual_url = self.remote_url(name)?; + if actual_url != configured.url().as_str() { + return Err(GitError::ForbiddenRemoteUrl); + } + let local_id = self + .repository + .head_id() + .map_err(|_| GitError::UnbornHead)? + .detach(); + let remote_ref = format!("refs/remotes/{name}/{branch}"); + let remote_id = self + .repository + .find_reference(&remote_ref) + .map_err(|_| GitError::RemoteNotFound { + name: remote_ref.clone(), + })? + .into_fully_peeled_id() + .map_err(invalid)? + .detach(); + let local_ids = self.ancestor_ids(local_id)?; + let remote_ids = self.ancestor_ids(remote_id)?; + let ahead = local_ids.difference(&remote_ids).count(); + let behind = remote_ids.difference(&local_ids).count(); + let incoming = self.commit_activities(remote_id, &local_ids, activity_limit)?; + let outgoing = self.commit_activities(local_id, &remote_ids, activity_limit)?; + Ok(GitDivergence { + branch, + status, + remote: GitRemoteStatus { + name: name.to_owned(), + url: actual_url, + ahead, + behind, + }, + incoming, + outgoing, + }) + } + + fn commit_activities( + &self, + tip: gix::hash::ObjectId, + excluded: &BTreeSet, + limit: usize, + ) -> Result, GitError> { + if limit == 0 { + return Ok(Vec::new()); + } + let commit = self + .repository + .find_object(tip) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)?; + let mut output = Vec::new(); + for info in commit.ancestors().all().map_err(invalid)? { + let info = info.map_err(invalid)?; + if excluded.contains(&info.id) { + continue; + } + output.push(self.commit_activity(info.id)?); + if output.len() >= limit { + break; + } + } + Ok(output) + } + + fn commit_activity(&self, id: gix::hash::ObjectId) -> Result { + let commit = self + .repository + .find_object(id) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)?; + let new_tree = commit.tree_id().map_err(invalid)?.detach(); + let old_tree = commit + .parent_ids() + .next() + .map(|parent| { + self.repository + .find_object(parent.detach()) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree_id() + .map_err(invalid) + .map(|tree| tree.detach()) + }) + .transpose()?; + let old = tree_map_by_id(&self.repository, old_tree)?; + let new = tree_map_by_id(&self.repository, Some(new_tree))?; + Ok(GitCommitActivity { + commit: git_log_entry(&commit)?, + changes: compare_maps(&old, &new), + }) + } + fn ahead_behind( &self, local: gix::hash::ObjectId, remote: gix::hash::ObjectId, ) -> Result<(usize, usize), GitError> { - let ancestors = |id| -> Result, GitError> { - let commit = self - .repository - .find_object(id) - .map_err(invalid)? - .peel_to_commit() - .map_err(invalid)?; - let mut ids = BTreeSet::from([id]); - for info in commit.ancestors().all().map_err(invalid)? { - ids.insert(info.map_err(invalid)?.id); - } - Ok(ids) - }; - let local_ids = ancestors(local)?; - let remote_ids = ancestors(remote)?; + let local_ids = self.ancestor_ids(local)?; + let remote_ids = self.ancestor_ids(remote)?; Ok(( local_ids.difference(&remote_ids).count(), remote_ids.difference(&local_ids).count(), )) } + fn ancestor_ids( + &self, + id: gix::hash::ObjectId, + ) -> Result, GitError> { + let commit = self + .repository + .find_object(id) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)?; + let mut ids = BTreeSet::from([id]); + for info in commit.ancestors().all().map_err(invalid)? { + ids.insert(info.map_err(invalid)?.id); + } + Ok(ids) + } + fn update_remote_tracking( &self, remote: &str, @@ -2329,16 +2488,7 @@ impl GitRepository { } let info = info.map_err(invalid)?; let commit = info.object().map_err(invalid)?; - let decoded = commit.decode().map_err(invalid)?; - let author = decoded.author().map_err(invalid)?; - output.push(GitLogEntry { - id: commit.id.to_string(), - parents: decoded.parents().map(|id| id.to_string()).collect(), - author_name: author.name.to_str_lossy().into_owned(), - author_email: author.email.to_str_lossy().into_owned(), - message: decoded.message.to_str_lossy().into_owned(), - timestamp: author.time().map_err(invalid)?.seconds, - }); + output.push(git_log_entry(&commit)?); } Ok(output) } @@ -2516,6 +2666,19 @@ fn append_diff_lines(output: &mut Vec, prefix: u8, contents: &[u8]) { } } +fn git_log_entry(commit: &gix::Commit<'_>) -> Result { + let decoded = commit.decode().map_err(invalid)?; + let author = decoded.author().map_err(invalid)?; + Ok(GitLogEntry { + id: commit.id.to_string(), + parents: decoded.parents().map(|id| id.to_string()).collect(), + author_name: author.name.to_str_lossy().into_owned(), + author_email: author.email.to_str_lossy().into_owned(), + message: decoded.message.to_str_lossy().into_owned(), + timestamp: author.time().map_err(invalid)?.seconds, + }) +} + impl EntryCommitter for GitRepository { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message()) diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index dc2c0b1..e6e8a4b 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -15,6 +15,7 @@ pub mod generate; pub mod git; pub mod kdbx; pub mod mobile; +pub mod mobile_home; pub mod mobile_onboarding; pub mod mutation; pub mod otp; diff --git a/crates/storage/src/mobile_home.rs b/crates/storage/src/mobile_home.rs new file mode 100644 index 0000000..e882b2a --- /dev/null +++ b/crates/storage/src/mobile_home.rs @@ -0,0 +1,979 @@ +//! Storage-owned remote activity and pull-to-refresh models for iPhone Home. + +use std::{ + collections::BTreeSet, + error::Error, + fmt, + path::Path, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + config::{Config, ConfigError, GitRemote}, + git::{ + GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitIdentity, + GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, + }, + repository::{Repository, RepositoryError}, + secret_store::{ + NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError, + }, +}; + +const ACTIVITY_LIMIT: usize = 50; +const STALE_AFTER_SECONDS: i64 = 5 * 60; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomeFreshness { + NeverRefreshed, + Cached, + Current, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomeChangeKind { + PasswordEntry, + RecipientPolicy, + RecipientSignature, + RepositoryFile, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomeChangeStatus { + Added, + Modified, + Deleted, +} + +impl From for MobileHomeChangeStatus { + fn from(kind: GitChangeKind) -> Self { + match kind { + GitChangeKind::Added => Self::Added, + GitChangeKind::Modified => Self::Modified, + GitChangeKind::Deleted => Self::Deleted, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeSummaryRow { + id: String, + title: String, + detail: String, + system_image: String, +} + +impl MobileHomeSummaryRow { + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn system_image(&self) -> &str { + &self.system_image + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeChange { + id: String, + title: String, + detail: String, + system_image: String, + kind: MobileHomeChangeKind, + status: MobileHomeChangeStatus, +} + +impl MobileHomeChange { + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn system_image(&self) -> &str { + &self.system_image + } + + pub fn kind(&self) -> MobileHomeChangeKind { + self.kind + } + + pub fn status(&self) -> MobileHomeChangeStatus { + self.status + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeCommit { + id: String, + title: String, + detail: String, + system_image: String, + timestamp: i64, + changes: Vec, +} + +impl MobileHomeCommit { + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn system_image(&self) -> &str { + &self.system_image + } + + pub fn timestamp(&self) -> i64 { + self.timestamp + } + + pub fn changes(&self) -> &[MobileHomeChange] { + &self.changes + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeNotice { + title: String, + detail: String, + system_image: String, +} + +impl MobileHomeNotice { + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn system_image(&self) -> &str { + &self.system_image + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomePage { + freshness: MobileHomeFreshness, + refreshed_at: Option, + summaries: Vec, + incoming: Vec, + outgoing: Vec, + incoming_total: usize, + outgoing_total: usize, + notice: Option, +} + +impl MobileHomePage { + pub fn freshness(&self) -> MobileHomeFreshness { + self.freshness + } + + pub fn refreshed_at(&self) -> Option { + self.refreshed_at + } + + pub fn summaries(&self) -> &[MobileHomeSummaryRow] { + &self.summaries + } + + pub fn incoming(&self) -> &[MobileHomeCommit] { + &self.incoming + } + + pub fn outgoing(&self) -> &[MobileHomeCommit] { + &self.outgoing + } + + pub fn incoming_total(&self) -> usize { + self.incoming_total + } + + pub fn outgoing_total(&self) -> usize { + self.outgoing_total + } + + pub fn notice(&self) -> Option<&MobileHomeNotice> { + self.notice.as_ref() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomePhase { + Validating, + Authenticating, + Receiving, + Integrating, + Finishing, +} + +impl From for MobileHomePhase { + fn from(phase: GitProgressPhase) -> Self { + match phase { + GitProgressPhase::Validating => Self::Validating, + GitProgressPhase::Authenticating => Self::Authenticating, + GitProgressPhase::Receiving => Self::Receiving, + GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating, + GitProgressPhase::Refreshing => Self::Finishing, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeProgress { + phase: MobileHomePhase, + title: String, + detail: String, +} + +impl MobileHomeProgress { + pub fn phase(&self) -> MobileHomePhase { + self.phase + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +pub struct MobileHomeOperation { + control: GitOperationControl, + phase: Arc>, +} + +impl Default for MobileHomeOperation { + fn default() -> Self { + let phase = Arc::new(Mutex::new(MobileHomePhase::Validating)); + let observed = Arc::clone(&phase); + Self { + control: GitOperationControl::new(move |phase| { + if let Ok(mut current) = observed.lock() { + *current = phase.into(); + } + }), + phase, + } + } +} + +impl MobileHomeOperation { + pub fn cancel(&self) { + self.control.cancel(); + } + + pub fn progress(&self) -> MobileHomeProgress { + progress_copy( + self.phase + .lock() + .map_or(MobileHomePhase::Validating, |phase| *phase), + ) + } + + pub fn cached(&self) -> Result { + let storage = MobileHomeStorage::load()?; + let refreshed_at = storage.config.mobile_home_refreshed_at(); + storage.page( + if refreshed_at.is_some() { + MobileHomeFreshness::Cached + } else { + MobileHomeFreshness::NeverRefreshed + }, + refreshed_at, + None, + ) + } + + pub fn refresh_if_stale(&self) -> Result { + let storage = MobileHomeStorage::load()?; + let now = unix_seconds()?; + if !is_stale(storage.config.mobile_home_refreshed_at(), now) { + return storage.page( + MobileHomeFreshness::Cached, + storage.config.mobile_home_refreshed_at(), + None, + ); + } + storage.refresh(now, &self.control) + } + + pub fn refresh(&self) -> Result { + MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control) + } + + pub fn pull(&self) -> Result { + MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) + } +} + +struct MobileHomeStorage { + config: Config, + git: GitRepository, + remote: GitRemote, +} + +impl MobileHomeStorage { + fn load() -> Result { + let config = Config::load(None).map_err(MobileHomeError::from_config)?; + let repository = + Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?; + let git = GitRepository::open(&repository, GitIdentity::ironstorage()) + .map_err(MobileHomeError::from_git)?; + let remote = config + .git_remote(None) + .cloned() + .ok_or_else(MobileHomeError::missing_remote)?; + Ok(Self { + config, + git, + remote, + }) + } + + fn refresh( + &self, + now: i64, + control: &GitOperationControl, + ) -> Result { + let store = self.credentials()?; + let refreshed = self.git.fetch_with_transport_controlled( + &self.remote, + &store, + &crate::git::EmbeddedFetchTransport, + control, + ); + let locked = store.lock(); + refreshed.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) + } + + fn pull( + &self, + now: i64, + control: &GitOperationControl, + ) -> Result { + let store = self.credentials()?; + let pulled = self.git.pull_with_transport_controlled( + &self.remote, + None, + &store, + &crate::git::EmbeddedFetchTransport, + control, + ); + let locked = store.lock(); + let outcome = pulled.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(pull_notice(outcome))) + .map_err(MobileHomeError::after_pull) + } + + fn credentials(&self) -> Result { + let store = NativeSecretStore::system( + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) + .map_err(MobileHomeError::from_secret)?; + store.unlock().map_err(MobileHomeError::from_secret)?; + Ok(store) + } + + fn current_page( + &self, + now: i64, + notice: Option, + ) -> Result { + let persisted = self.config.update_mobile_home_refresh(now).is_ok(); + let notice = if persisted { + notice + } else { + Some(MobileHomeNotice { + title: notice + .as_ref() + .map_or("Status Refreshed", MobileHomeNotice::title) + .to_owned(), + detail: "The repository was updated, but the refresh time could not be cached." + .to_owned(), + system_image: "exclamationmark.triangle".to_owned(), + }) + }; + self.page(MobileHomeFreshness::Current, Some(now), notice) + .map_err(MobileHomeError::after_refresh) + } + + fn page( + &self, + freshness: MobileHomeFreshness, + refreshed_at: Option, + notice: Option, + ) -> Result { + let divergence = self + .git + .divergence(&self.remote, ACTIVITY_LIMIT) + .map_err(MobileHomeError::from_git)?; + Ok(page_from_divergence( + &divergence, + freshness, + refreshed_at, + notice, + )) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileHomeErrorKind { + MissingConfiguration, + Configuration, + Authentication, + Conflict, + DirtyLocalChanges, + Offline, + Interrupted, + SecureStorage, + Repository, + PartialProgress, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileHomeError { + kind: MobileHomeErrorKind, + title: &'static str, + detail: String, +} + +impl MobileHomeError { + pub fn kind(&self) -> MobileHomeErrorKind { + self.kind + } + + pub fn title(&self) -> &str { + self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + fn missing_remote() -> Self { + Self { + kind: MobileHomeErrorKind::Configuration, + title: "Remote Is Not Configured", + detail: "Set up an HTTPS password-store remote before refreshing Home.".to_owned(), + } + } + + fn from_config(error: ConfigError) -> Self { + match error { + ConfigError::NotFound { .. } => Self { + kind: MobileHomeErrorKind::MissingConfiguration, + title: "Set Up Password Store", + detail: "Complete first-run setup before loading remote activity.".to_owned(), + }, + _ => Self { + kind: MobileHomeErrorKind::Configuration, + title: "Configuration Is Unavailable", + detail: "Check the local IronStorage configuration and try again.".to_owned(), + }, + } + } + + fn from_repository(_error: RepositoryError) -> Self { + Self { + kind: MobileHomeErrorKind::Repository, + title: "Password Store Is Unavailable", + detail: "The local password-store clone could not be opened.".to_owned(), + } + } + + fn from_git(error: GitError) -> Self { + match error { + GitError::AuthenticationFailed + | GitError::CredentialsUnavailable + | GitError::CredentialAccessDenied + | GitError::CredentialCancelled => Self { + kind: MobileHomeErrorKind::Authentication, + title: "Authentication Failed", + detail: "Update the application token in Preferences and try again.".to_owned(), + }, + GitError::MergeConflicts { conflicts } => { + let names = conflicts + .iter() + .take(3) + .map(|conflict| display_path(conflict.path())) + .collect::>(); + let suffix = if conflicts.len() > names.len() { + format!(" and {} more", conflicts.len() - names.len()) + } else { + String::new() + }; + Self { + kind: MobileHomeErrorKind::Conflict, + title: "Pull Needs Conflict Resolution", + detail: format!( + "Remote changes were fetched, but the local clone was not changed. Resolve {}{} and pull again.", + names.join(", "), + suffix + ), + } + } + GitError::DirtyWorktree => Self { + kind: MobileHomeErrorKind::DirtyLocalChanges, + title: "Local Changes Need Attention", + detail: "Commit or discard local changes before pulling remote activity.".to_owned(), + }, + GitError::NetworkUnavailable => Self { + kind: MobileHomeErrorKind::Offline, + title: "Server Is Offline", + detail: "Cached activity remains available. Check the network and try again." + .to_owned(), + }, + GitError::TlsFailed => Self { + kind: MobileHomeErrorKind::Offline, + title: "Secure Connection Failed", + detail: "The HTTPS server identity could not be verified. Cached activity was not replaced." + .to_owned(), + }, + GitError::Cancelled => Self { + kind: MobileHomeErrorKind::Interrupted, + title: "Refresh Interrupted", + detail: "The local clone remains recoverable. Pull or refresh again when ready." + .to_owned(), + }, + GitError::ForbiddenRemoteUrl => Self { + kind: MobileHomeErrorKind::Configuration, + title: "HTTPS Remote Required", + detail: "The configured credential-free HTTPS remote does not match the local clone." + .to_owned(), + }, + _ => Self { + kind: MobileHomeErrorKind::Repository, + title: "Remote Activity Is Unavailable", + detail: "The storage-owned Git state could not be loaded.".to_owned(), + }, + } + } + + fn from_secret(error: SecretStoreError) -> Self { + let detail = match error { + SecretStoreError::Denied => "Access to the application token was denied.", + SecretStoreError::Cancelled => "Application-token access was cancelled.", + SecretStoreError::Missing => { + "No application token is stored. Update it in Preferences." + } + _ => "Protected application-token storage is unavailable.", + }; + Self { + kind: MobileHomeErrorKind::SecureStorage, + title: "Token Is Unavailable", + detail: detail.to_owned(), + } + } + + fn partial_secret(_error: SecretStoreError) -> Self { + Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Remote Updated With a Warning", + detail: "Remote data was received, but protected credential state could not be closed cleanly. Restart IronStorage before retrying." + .to_owned(), + } + } + + fn after_refresh(_error: Self) -> Self { + Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Remote Refreshed, Activity Unavailable", + detail: "Remote tracking data was updated, but the refreshed activity page could not be built. The local clone remains unchanged." + .to_owned(), + } + } + + fn after_pull(_error: Self) -> Self { + Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Pull Completed, Activity Unavailable", + detail: "The local clone was updated, but the refreshed activity page could not be built. Reload Home to retry." + .to_owned(), + } + } +} + +impl fmt::Display for MobileHomeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.title, self.detail) + } +} + +impl Error for MobileHomeError {} + +fn page_from_divergence( + divergence: &GitDivergence, + freshness: MobileHomeFreshness, + refreshed_at: Option, + notice: Option, +) -> MobileHomePage { + let ahead = divergence.remote().ahead(); + let behind = divergence.remote().behind(); + let local_paths = divergence + .status() + .staged() + .iter() + .chain(divergence.status().unstaged()) + .map(|change| change.path().to_owned()) + .collect::>(); + let divergence_title = match (behind, ahead) { + (0, 0) => "Up to Date".to_owned(), + (behind, 0) => format!("{behind} Incoming"), + (0, ahead) => format!("{ahead} Outgoing"), + (behind, ahead) => format!("{behind} Incoming · {ahead} Outgoing"), + }; + let divergence_detail = format!( + "{} to pull · {} to push", + commit_count(behind), + commit_count(ahead) + ); + let local_count = local_paths.len(); + let summaries = vec![ + MobileHomeSummaryRow { + id: "tracked-branch".to_owned(), + title: format!("{}/{}", divergence.remote().name(), divergence.branch()), + detail: "Tracked HTTPS branch".to_owned(), + system_image: "point.3.connected.trianglepath.dotted".to_owned(), + }, + MobileHomeSummaryRow { + id: "divergence".to_owned(), + title: divergence_title, + detail: divergence_detail, + system_image: if ahead == 0 && behind == 0 { + "checkmark.circle".to_owned() + } else { + "arrow.triangle.2.circlepath".to_owned() + }, + }, + MobileHomeSummaryRow { + id: "working-tree".to_owned(), + title: if local_count == 0 { + "Working Tree Clean".to_owned() + } else { + format!("{} Local", change_count(local_count)) + }, + detail: if local_count == 0 { + "No uncommitted password-store changes".to_owned() + } else { + "Commit or discard these changes before pulling".to_owned() + }, + system_image: if local_count == 0 { + "checkmark.shield".to_owned() + } else { + "exclamationmark.triangle".to_owned() + }, + }, + ]; + MobileHomePage { + freshness, + refreshed_at, + summaries, + incoming: divergence + .incoming() + .iter() + .map(|activity| mobile_commit(activity, true)) + .collect(), + outgoing: divergence + .outgoing() + .iter() + .map(|activity| mobile_commit(activity, false)) + .collect(), + incoming_total: behind, + outgoing_total: ahead, + notice, + } +} + +fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit { + let commit = activity.commit(); + let title = commit + .message() + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| display_text(line.trim(), 160)) + .filter(|line| !line.is_empty()) + .unwrap_or_else(|| "Untitled Commit".to_owned()); + let author = display_text(commit.author_name(), 80); + let changes = activity + .changes() + .iter() + .enumerate() + .map(|(index, change)| { + mobile_change( + format!("{}:{index}", commit.id()), + change.path(), + change.kind(), + ) + }) + .collect::>(); + MobileHomeCommit { + id: commit.id().to_owned(), + title, + detail: format!( + "{} · {}", + if author.is_empty() { + "Unknown author" + } else { + &author + }, + change_count(changes.len()) + ), + system_image: if incoming { + "arrow.down.circle".to_owned() + } else { + "arrow.up.circle".to_owned() + }, + timestamp: commit.timestamp(), + changes, + } +} + +fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChange { + let status = MobileHomeChangeStatus::from(kind); + let status_name = match status { + MobileHomeChangeStatus::Added => "Added", + MobileHomeChangeStatus::Modified => "Modified", + MobileHomeChangeStatus::Deleted => "Deleted", + }; + let file_name = path.file_name().and_then(|name| name.to_str()); + let (title, detail, system_image, change_kind) = + if path.extension().is_some_and(|extension| extension == "gpg") { + ( + display_path(&path.with_extension("")), + format!("{status_name} password entry"), + "key".to_owned(), + MobileHomeChangeKind::PasswordEntry, + ) + } else if file_name == Some(".gpg-id") { + ( + policy_title(path, "recipients"), + format!("{status_name} recipient policy"), + "person.2".to_owned(), + MobileHomeChangeKind::RecipientPolicy, + ) + } else if file_name == Some(".gpg-id.sig") { + ( + policy_title(path, "recipient signature"), + format!("{status_name} recipient signature"), + "signature".to_owned(), + MobileHomeChangeKind::RecipientSignature, + ) + } else { + ( + display_path(path), + format!("{status_name} repository file"), + "doc".to_owned(), + MobileHomeChangeKind::RepositoryFile, + ) + }; + MobileHomeChange { + id, + title, + detail, + system_image, + kind: change_kind, + status, + } +} + +fn policy_title(path: &Path, suffix: &str) -> String { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else( + || format!("Root {suffix}"), + |parent| format!("{} {suffix}", display_path(parent)), + ) +} + +fn display_path(path: &Path) -> String { + display_text(&path.to_string_lossy(), 240) +} + +fn display_text(value: &str, maximum: usize) -> String { + let mut output = value + .chars() + .map(|character| { + if character.is_control() { + '\u{fffd}' + } else { + character + } + }) + .take(maximum + 1) + .collect::(); + if output.chars().count() > maximum { + output = output.chars().take(maximum.saturating_sub(1)).collect(); + output.push('…'); + } + output +} + +fn commit_count(count: usize) -> String { + format!("{count} commit{}", if count == 1 { "" } else { "s" }) +} + +fn change_count(count: usize) -> String { + format!("{count} change{}", if count == 1 { "" } else { "s" }) +} + +fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice { + let (title, detail, system_image) = match outcome { + PullOutcome::UpToDate => ( + "Already Up to Date", + "The local password store already contains every fetched commit.", + "checkmark.circle", + ), + PullOutcome::FastForward => ( + "Password Store Updated", + "Remote commits were applied without changing local history.", + "arrow.down.circle", + ), + PullOutcome::Merged => ( + "Remote Changes Merged", + "Remote and local commits were merged by storage.", + "arrow.triangle.merge", + ), + }; + MobileHomeNotice { + title: title.to_owned(), + detail: detail.to_owned(), + system_image: system_image.to_owned(), + } +} + +fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress { + let (title, detail) = match phase { + 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 => ( + "Updating Password Store", + "Integrating fetched commits into the local clone.", + ), + MobileHomePhase::Finishing => { + ("Updating Home", "Building current activity and divergence.") + } + }; + MobileHomeProgress { + phase, + title: title.to_owned(), + detail: detail.to_owned(), + } +} + +fn unix_seconds() -> Result { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| MobileHomeError { + kind: MobileHomeErrorKind::Configuration, + title: "System Time Is Invalid", + detail: "Correct the device time before refreshing remote activity.".to_owned(), + })? + .as_secs(); + i64::try_from(seconds).map_err(|_| MobileHomeError { + kind: MobileHomeErrorKind::Configuration, + title: "System Time Is Invalid", + detail: "Correct the device time before refreshing remote activity.".to_owned(), + }) +} + +fn is_stale(refreshed_at: Option, now: i64) -> bool { + !matches!( + refreshed_at, + Some(refreshed_at) + if refreshed_at <= now && now.saturating_sub(refreshed_at) < STALE_AFTER_SECONDS + ) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::git::{GitChangeKind, GitError}; + + use super::{ + MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale, + mobile_change, + }; + + #[test] + fn freshness_and_display_safety_are_storage_owned() { + assert!(is_stale(None, 1_000)); + assert!(!is_stale(Some(900), 1_000)); + assert!(is_stale(Some(699), 1_000)); + assert!(is_stale(Some(1_001), 1_000)); + assert_eq!(display_text("line\nsecret", 20), "line�secret"); + assert_eq!(display_text("abcdef", 4), "abc…"); + } + + #[test] + fn changed_password_paths_are_classified_before_presentation() { + let entry = mobile_change( + "commit:0".to_owned(), + Path::new("work/mail.gpg"), + GitChangeKind::Modified, + ); + assert_eq!(entry.title(), "work/mail"); + assert_eq!(entry.kind(), MobileHomeChangeKind::PasswordEntry); + assert_eq!(entry.status(), MobileHomeChangeStatus::Modified); + let policy = mobile_change( + "commit:1".to_owned(), + Path::new("team/.gpg-id"), + GitChangeKind::Added, + ); + assert_eq!(policy.title(), "team recipients"); + assert_eq!(policy.kind(), MobileHomeChangeKind::RecipientPolicy); + } + + #[test] + fn failures_are_typed_and_secret_safe() { + let offline = super::MobileHomeError::from_git(GitError::NetworkUnavailable); + assert_eq!(offline.kind(), MobileHomeErrorKind::Offline); + let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree); + assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges); + assert!(!offline.to_string().contains("token-value")); + } +} diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 168a3a2..3e9b555 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -453,6 +453,23 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult { let snapshot = git.snapshot(Some(remote), 5)?; assert_eq!(snapshot.remote().expect("remote status").ahead(), 1); assert_eq!(snapshot.remote().expect("remote status").behind(), 1); + let divergence = git.divergence(remote, 10)?; + assert_eq!(divergence.remote().ahead(), 1); + assert_eq!(divergence.remote().behind(), 1); + assert_eq!(divergence.incoming().len(), 1); + assert_eq!(divergence.incoming()[0].commit().message(), "Remote change"); + assert_eq!(divergence.incoming()[0].changes().len(), 1); + assert_eq!( + divergence.incoming()[0].changes()[0].path(), + Path::new("secret.gpg") + ); + assert_eq!(divergence.outgoing().len(), 1); + assert_eq!(divergence.outgoing()[0].commit().message(), "Local change"); + let totals_only = git.divergence(remote, 0)?; + assert_eq!(totals_only.remote().ahead(), 1); + assert_eq!(totals_only.remote().behind(), 1); + assert!(totals_only.incoming().is_empty()); + assert!(totals_only.outgoing().is_empty()); assert_eq!( git.resolve_fetched(