diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index aa1fa7e..1589a27 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -1523,6 +1523,138 @@ public func FfiConverterTypeMobilePage_lower(_ value: MobilePage) -> RustBuffer } +public struct MobilePasswordPage: Equatable, Hashable { + public var id: String + public var path: String + public var title: String + public var rows: [MobilePasswordRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, path: String, title: String, rows: [MobilePasswordRow]) { + self.id = id + self.path = path + self.title = title + self.rows = rows + } + + + + +} + +#if compiler(>=6) +extension MobilePasswordPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePasswordPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePasswordPage { + return + try MobilePasswordPage( + id: FfiConverterString.read(from: &buf), + path: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + rows: FfiConverterSequenceTypeMobilePasswordRow.read(from: &buf) + ) + } + + public static func write(_ value: MobilePasswordPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterSequenceTypeMobilePasswordRow.write(value.rows, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordPage_lift(_ buf: RustBuffer) throws -> MobilePasswordPage { + return try FfiConverterTypeMobilePasswordPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordPage_lower(_ value: MobilePasswordPage) -> RustBuffer { + return FfiConverterTypeMobilePasswordPage.lower(value) +} + + +public struct MobilePasswordRow: Equatable, Hashable { + public var id: String + public var path: String + public var title: String + public var detail: String + public var systemImage: String + public var kind: MobilePasswordRowKind + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, path: String, title: String, detail: String, systemImage: String, kind: MobilePasswordRowKind) { + self.id = id + self.path = path + self.title = title + self.detail = detail + self.systemImage = systemImage + self.kind = kind + } + + + + +} + +#if compiler(>=6) +extension MobilePasswordRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePasswordRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePasswordRow { + return + try MobilePasswordRow( + id: FfiConverterString.read(from: &buf), + path: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + kind: FfiConverterTypeMobilePasswordRowKind.read(from: &buf) + ) + } + + public static func write(_ value: MobilePasswordRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterTypeMobilePasswordRowKind.write(value.kind, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordRow_lift(_ buf: RustBuffer) throws -> MobilePasswordRow { + return try FfiConverterTypeMobilePasswordRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordRow_lower(_ value: MobilePasswordRow) -> RustBuffer { + return FfiConverterTypeMobilePasswordRow.lower(value) +} + + public struct MobileShell: Equatable, Hashable { public var selectedTab: MobileTab public var pages: [MobilePage] @@ -2372,6 +2504,238 @@ public func FfiConverterTypeMobileOnboardingPhase_lower(_ value: MobileOnboardin + +public enum MobilePasswordErrorKind: Equatable, Hashable { + + case missingConfiguration + case configuration + case invalidPath + case directoryMissing + case repository + + + + + +} + +#if compiler(>=6) +extension MobilePasswordErrorKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePasswordErrorKind: FfiConverterRustBuffer { + typealias SwiftType = MobilePasswordErrorKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePasswordErrorKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .missingConfiguration + + case 2: return .configuration + + case 3: return .invalidPath + + case 4: return .directoryMissing + + case 5: return .repository + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePasswordErrorKind, into buf: inout [UInt8]) { + switch value { + + + case .missingConfiguration: + writeInt(&buf, Int32(1)) + + + case .configuration: + writeInt(&buf, Int32(2)) + + + case .invalidPath: + writeInt(&buf, Int32(3)) + + + case .directoryMissing: + writeInt(&buf, Int32(4)) + + + case .repository: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordErrorKind_lift(_ buf: RustBuffer) throws -> MobilePasswordErrorKind { + return try FfiConverterTypeMobilePasswordErrorKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordErrorKind_lower(_ value: MobilePasswordErrorKind) -> RustBuffer { + return FfiConverterTypeMobilePasswordErrorKind.lower(value) +} + + + +public +enum MobilePasswordFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Failed(kind: MobilePasswordErrorKind, title: String, detail: String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension MobilePasswordFfiError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePasswordFfiError: FfiConverterRustBuffer { + typealias SwiftType = MobilePasswordFfiError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePasswordFfiError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Failed( + kind: try FfiConverterTypeMobilePasswordErrorKind.read(from: &buf), + title: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePasswordFfiError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Failed(kind,title,detail): + writeInt(&buf, Int32(1)) + FfiConverterTypeMobilePasswordErrorKind.write(kind, into: &buf) + FfiConverterString.write(title, into: &buf) + FfiConverterString.write(detail, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordFfiError_lift(_ buf: RustBuffer) throws -> MobilePasswordFfiError { + return try FfiConverterTypeMobilePasswordFfiError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordFfiError_lower(_ value: MobilePasswordFfiError) -> RustBuffer { + return FfiConverterTypeMobilePasswordFfiError.lower(value) +} + + + +public enum MobilePasswordRowKind: Equatable, Hashable { + + case directory + case entry + + + + + +} + +#if compiler(>=6) +extension MobilePasswordRowKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePasswordRowKind: FfiConverterRustBuffer { + typealias SwiftType = MobilePasswordRowKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePasswordRowKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .directory + + case 2: return .entry + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePasswordRowKind, into buf: inout [UInt8]) { + switch value { + + + case .directory: + writeInt(&buf, Int32(1)) + + + case .entry: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordRowKind_lift(_ buf: RustBuffer) throws -> MobilePasswordRowKind { + return try FfiConverterTypeMobilePasswordRowKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePasswordRowKind_lower(_ value: MobilePasswordRowKind) -> RustBuffer { + return FfiConverterTypeMobilePasswordRowKind.lower(value) +} + + + public enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -2637,6 +3001,30 @@ fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { + typealias SwiftType = String? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterString.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 FfiConverterString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2785,6 +3173,31 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer { return seq } } + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBuffer { + typealias SwiftType = [MobilePasswordRow] + + public static func write(_ value: [MobilePasswordRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobilePasswordRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobilePasswordRow] { + let len: Int32 = try readInt(&buf) + var seq = [MobilePasswordRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobilePasswordRow.read(from: &buf)) + } + return seq + } +} public func mobileHomeOperation() -> MobileHomeOperation { return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { uniffiCallStatus in @@ -2803,6 +3216,14 @@ public func mobileOnboardingOperation(serverUrl: String, account: String, reposi ) }) } +public func mobilePasswordPage(path: String?)throws -> MobilePasswordPage { + return try FfiConverterTypeMobilePasswordPage_lift(try rustCallWithError(FfiConverterTypeMobilePasswordFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_mobile_password_page( + FfiConverterOptionString.lower(path),uniffiCallStatus + ) +}) +} public func mobileShell() -> MobileShell { return try! FfiConverterTypeMobileShell_lift(try! rustCall() { uniffiCallStatus in @@ -2862,6 +3283,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_func_mobile_password_page() != 64312) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 25d87d0..4f85b1d 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -324,6 +324,11 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus * uint64_t uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(RustBuffer server_url, RustBuffer account, RustBuffer repository_path, RustBuffer application_token, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_PASSWORD_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_PASSWORD_PAGE +RustBuffer uniffi_ironstorage_apple_fn_func_mobile_password_page(RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell(RustCallStatus *_Nonnull out_status @@ -621,6 +626,12 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION uint16_t uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_PASSWORD_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_PASSWORD_PAGE +uint16_t uniffi_ironstorage_apple_checksum_func_mobile_password_page(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 5bad480..151ade1 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -25,6 +25,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } } +@MainActor +private protocol MobileTabRoot: AnyObject { + var shellTab: MobileTab { get } +} + @MainActor private final class AppContext: NSObject, UITabBarControllerDelegate { private let tabs = UITabBarController() @@ -34,7 +39,9 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { func makeRootController() -> UIViewController { let shell = mobileShellFixture(state: .loading) navigationControllers = shell.pages.map { page in - let root = ShellViewController(page: page) + let root: UIViewController = page.tab == .passwords + ? PasswordDirectoryViewController(shellPage: page) + : ShellViewController(page: page) let navigation = UINavigationController(rootViewController: root) navigation.navigationBar.prefersLargeTitles = true navigation.tabBarItem = UITabBarItem( @@ -56,7 +63,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { ) { guard let navigation = viewController as? UINavigationController, - let root = navigation.viewControllers.first as? ShellViewController + let root = navigation.viewControllers.first as? MobileTabRoot else { return } @@ -86,7 +93,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { } @MainActor -private final class ShellViewController: UITableViewController { +private final class ShellViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab: MobileTab private var page: MobilePage private var loadTask: Task? @@ -607,6 +614,289 @@ private final class ShellViewController: UITableViewController { } } +@MainActor +private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot { + fileprivate let shellTab = MobileTab.passwords + private let path: String? + private var shellPage: MobilePage? + private var directoryPage: MobilePasswordPage? + private var loadTask: Task? + private var loadGeneration = 0 + + init(shellPage: MobilePage, path: String? = nil) { + self.shellPage = shellPage + self.path = path + super.init(style: .insetGrouped) + title = shellPage.title + navigationItem.largeTitleDisplayMode = path == nil ? .always : .never + refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageLocalStoreDidChange, + object: nil + ) + } + + private init(path: String, title: String) { + self.path = path + shellPage = nil + super.init(style: .insetGrouped) + self.title = title + navigationItem.largeTitleDisplayMode = .never + refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageLocalStoreDidChange, + object: nil + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + loadTask?.cancel() + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + super.viewDidLoad() + if let shellPage { + apply(shellPage) + } else { + showLoading() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reload() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + loadGeneration += 1 + loadTask?.cancel() + refreshControl?.endRefreshing() + } + + override func numberOfSections(in tableView: UITableView) -> Int { + directoryPage?.rows.isEmpty == false ? 1 : 0 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + directoryPage?.rows.count ?? 0 + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + guard let row = directoryPage?.rows[indexPath.row] else { return cell } + var content = cell.defaultContentConfiguration() + content.image = UIImage(systemName: row.systemImage) + content.text = row.title + content.secondaryText = row.detail + content.textProperties.numberOfLines = 2 + content.secondaryTextProperties.numberOfLines = 1 + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + cell.accessibilityLabel = "\(row.title), \(row.detail)" + cell.accessibilityHint = row.kind == .directory + ? "Opens this password folder." + : "Opens the locked password viewer." + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let row = directoryPage?.rows[indexPath.row] else { return } + let controller: UIViewController = switch row.kind { + case .directory: + PasswordDirectoryViewController(path: row.path, title: row.title) + case .entry: + LockedPasswordViewController(entry: row) + } + navigationController?.pushViewController(controller, animated: true) + } + + @objc private func refreshRequested() { + reload() + } + + @objc private func localStoreDidChange() { + guard viewIfLoaded?.window != nil else { return } + reload() + } + + private func reload() { + loadGeneration += 1 + let generation = loadGeneration + let selectedID = tableView.indexPathForSelectedRow.flatMap { + directoryPage?.rows[$0.row].id + } + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { return } + if path == nil { + let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value + guard + !Task.isCancelled, + generation == loadGeneration, + let page = shell.pages.first(where: { $0.tab == .passwords }) + else { return } + apply(page) + guard page.state == .ready else { return } + } else { + showLoading() + } + let path = self.path + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try mobilePasswordPage(path: path) + ) + } catch let error as MobilePasswordFfiError { + return .failure(PasswordFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, generation == loadGeneration else { return } + finish(result, selectedID: selectedID) + } + } + + private func apply(_ page: MobilePage) { + shellPage = page + title = page.title + guard page.state == .ready else { + directoryPage = nil + tableView.reloadData() + refreshControl?.endRefreshing() + var configuration = page.state == .loading + ? UIContentUnavailableConfiguration.loading() + : UIContentUnavailableConfiguration.empty() + configuration.image = page.state == .loading + ? nil + : UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield") + configuration.text = page.stateTitle + configuration.secondaryText = page.stateDetail + contentUnavailableConfiguration = configuration + return + } + showLoading() + } + + private func finish( + _ result: Result, + selectedID: String? + ) { + refreshControl?.endRefreshing() + switch result { + case let .success(page): + directoryPage = page + title = page.title + contentUnavailableConfiguration = nil + tableView.reloadData() + if page.rows.isEmpty { + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "folder") + configuration.text = "Empty Folder" + configuration.secondaryText = "This password folder contains no entries." + contentUnavailableConfiguration = configuration + } else if let selectedID, + let row = page.rows.firstIndex(where: { $0.id == selectedID }) { + tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none) + } + case let .failure(failure): + if failure.kind == .directoryMissing, path != nil { + UIAccessibility.post( + notification: .announcement, + argument: "The password folder no longer exists." + ) + navigationController?.popViewController(animated: true) + return + } + directoryPage = nil + tableView.reloadData() + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "exclamationmark.triangle") + configuration.text = failure.title + configuration.secondaryText = failure.detail + contentUnavailableConfiguration = configuration + } + } + + private func showLoading() { + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = "Loading Passwords" + configuration.secondaryText = "Reading the locked folder model from storage." + contentUnavailableConfiguration = configuration + } +} + +private struct PasswordFailure: Error, Sendable { + let kind: MobilePasswordErrorKind + let title: String + let detail: String + + init(_ error: MobilePasswordFfiError) { + switch error { + case let .Failed(kind, title, detail): + self.kind = kind + self.title = title + self.detail = detail + } + } + + static let unexpected = PasswordFailure( + kind: .repository, + title: "Passwords Are Unavailable", + detail: "IronStorage could not load the storage-provided folder." + ) + + private init(kind: MobilePasswordErrorKind, title: String, detail: String) { + self.kind = kind + self.title = title + self.detail = detail + } +} + +@MainActor +private final class LockedPasswordViewController: UIViewController { + private let entry: MobilePasswordRow + + init(entry: MobilePasswordRow) { + self.entry = entry + super.init(nibName: nil, bundle: nil) + title = entry.title + navigationItem.largeTitleDisplayMode = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "lock.fill") + configuration.text = "Locked Password" + configuration.secondaryText = "Authenticate to reveal this password entry." + contentUnavailableConfiguration = configuration + view.accessibilityIdentifier = entry.id + } +} + private struct HomeFailure: Error, Sendable { let kind: MobileHomeErrorKind let title: String diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index ed81625..67aa053 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -19,6 +19,11 @@ use ironstorage::{ MobileOnboardingErrorKind as StorageOnboardingErrorKind, MobileOnboardingPhase as StorageOnboardingPhase, }, + mobile_passwords::{ + MobilePasswordError as StoragePasswordError, + MobilePasswordErrorKind as StoragePasswordErrorKind, + MobilePasswordRowKind as StoragePasswordRowKind, + }, }; uniffi::setup_scaffolding!(); @@ -381,6 +386,111 @@ impl MobileHomeOperation { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobilePasswordRowKind { + Directory, + Entry, +} + +impl From for MobilePasswordRowKind { + fn from(kind: StoragePasswordRowKind) -> Self { + match kind { + StoragePasswordRowKind::Directory => Self::Directory, + StoragePasswordRowKind::Entry => Self::Entry, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobilePasswordRow { + pub id: String, + pub path: String, + pub title: String, + pub detail: String, + pub system_image: String, + pub kind: MobilePasswordRowKind, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobilePasswordPage { + pub id: String, + pub path: String, + pub title: String, + pub rows: Vec, +} + +impl From for MobilePasswordPage { + fn from(page: ironstorage::mobile_passwords::MobilePasswordPage) -> Self { + Self { + id: page.id().to_owned(), + path: page.path().to_owned(), + title: page.title().to_owned(), + rows: page + .rows() + .iter() + .map(|row| MobilePasswordRow { + id: row.id().to_owned(), + path: row.path().to_owned(), + title: row.title().to_owned(), + detail: row.detail().to_owned(), + system_image: row.system_image().to_owned(), + kind: row.kind().into(), + }) + .collect(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobilePasswordErrorKind { + MissingConfiguration, + Configuration, + InvalidPath, + DirectoryMissing, + Repository, +} + +impl From for MobilePasswordErrorKind { + fn from(kind: StoragePasswordErrorKind) -> Self { + match kind { + StoragePasswordErrorKind::MissingConfiguration => Self::MissingConfiguration, + StoragePasswordErrorKind::Configuration => Self::Configuration, + StoragePasswordErrorKind::InvalidPath => Self::InvalidPath, + StoragePasswordErrorKind::DirectoryMissing => Self::DirectoryMissing, + StoragePasswordErrorKind::Repository => Self::Repository, + } + } +} + +#[derive(Debug, uniffi::Error)] +pub enum MobilePasswordFfiError { + Failed { + kind: MobilePasswordErrorKind, + title: String, + detail: String, + }, +} + +impl fmt::Display for MobilePasswordFfiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"), + } + } +} + +impl Error for MobilePasswordFfiError {} + +impl From for MobilePasswordFfiError { + fn from(error: StoragePasswordError) -> Self { + Self::Failed { + kind: error.kind().into(), + title: error.title().to_owned(), + detail: error.detail().to_owned(), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileOnboardingPhase { Validating, @@ -592,6 +702,15 @@ pub fn mobile_home_operation() -> Arc { }) } +#[uniffi::export] +pub fn mobile_password_page( + path: Option, +) -> Result { + ironstorage::mobile_passwords::MobilePasswordPage::load(path.as_deref()) + .map(Into::into) + .map_err(Into::into) +} + #[uniffi::export] pub fn mobile_onboarding_operation( server_url: String, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index e6e8a4b..a668ea6 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -17,6 +17,7 @@ pub mod kdbx; pub mod mobile; pub mod mobile_home; pub mod mobile_onboarding; +pub mod mobile_passwords; pub mod mutation; pub mod otp; pub mod presentation; diff --git a/crates/storage/src/mobile_passwords.rs b/crates/storage/src/mobile_passwords.rs new file mode 100644 index 0000000..41fb487 --- /dev/null +++ b/crates/storage/src/mobile_passwords.rs @@ -0,0 +1,286 @@ +//! Storage-owned, locked password navigation for native mobile frontends. + +use std::{error::Error, fmt}; + +use crate::{ + config::{Config, ConfigError}, + read::{ReadError, TreeNode, TreeNodeKind, list_tree}, + repository::{DirectoryPath, Repository, RepositoryError}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobilePasswordRowKind { + Directory, + Entry, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobilePasswordRow { + id: String, + path: String, + title: String, + detail: String, + system_image: String, + kind: MobilePasswordRowKind, +} + +impl MobilePasswordRow { + pub fn id(&self) -> &str { + &self.id + } + + pub fn path(&self) -> &str { + &self.path + } + + 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) -> MobilePasswordRowKind { + self.kind + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobilePasswordPage { + id: String, + path: String, + title: String, + rows: Vec, +} + +impl MobilePasswordPage { + pub fn load(path: Option<&str>) -> Result { + let config = Config::load(None).map_err(MobilePasswordError::from_config)?; + let repository = + Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; + Self::from_repository(&repository, path) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn rows(&self) -> &[MobilePasswordRow] { + &self.rows + } + + fn from_repository( + repository: &Repository, + path: Option<&str>, + ) -> Result { + let directory = match path { + Some(path) => DirectoryPath::parse(path).map_err(MobilePasswordError::repository)?, + None => DirectoryPath::root(), + }; + let tree = list_tree(repository, &directory).map_err(MobilePasswordError::from_read)?; + let path = directory + .as_path() + .to_str() + .ok_or_else(MobilePasswordError::invalid_path)? + .to_owned(); + let title = directory + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Passwords") + .to_owned(); + Ok(Self { + id: format!("directory:{path}"), + path, + title, + rows: tree.children().iter().map(mobile_row).collect(), + }) + } +} + +fn mobile_row(node: &TreeNode) -> MobilePasswordRow { + let path = node.path().to_owned(); + let (kind, prefix, detail, system_image) = match node.kind() { + TreeNodeKind::Directory => ( + MobilePasswordRowKind::Directory, + "directory", + "Folder", + "folder", + ), + TreeNodeKind::Entry => ( + MobilePasswordRowKind::Entry, + "entry", + "Locked password", + "key.fill", + ), + }; + MobilePasswordRow { + id: format!("{prefix}:{path}"), + path, + title: node.name().to_owned(), + detail: detail.to_owned(), + system_image: system_image.to_owned(), + kind, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobilePasswordErrorKind { + MissingConfiguration, + Configuration, + InvalidPath, + DirectoryMissing, + Repository, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobilePasswordError { + kind: MobilePasswordErrorKind, + title: String, + detail: String, +} + +impl MobilePasswordError { + pub fn kind(&self) -> MobilePasswordErrorKind { + self.kind + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + fn from_config(error: ConfigError) -> Self { + let kind = if matches!(error, ConfigError::NotFound { .. }) { + MobilePasswordErrorKind::MissingConfiguration + } else { + MobilePasswordErrorKind::Configuration + }; + Self { + kind, + title: "Passwords Are Unavailable".to_owned(), + detail: error.to_string(), + } + } + + fn repository(error: RepositoryError) -> Self { + let kind = match error { + RepositoryError::InvalidPath { .. } => MobilePasswordErrorKind::InvalidPath, + RepositoryError::NotFound { .. } => MobilePasswordErrorKind::DirectoryMissing, + _ => MobilePasswordErrorKind::Repository, + }; + Self { + kind, + title: match kind { + MobilePasswordErrorKind::DirectoryMissing => "Folder No Longer Exists", + MobilePasswordErrorKind::InvalidPath => "Invalid Password Folder", + _ => "Passwords Are Unavailable", + } + .to_owned(), + detail: error.to_string(), + } + } + + fn from_read(error: ReadError) -> Self { + match error { + ReadError::Repository(error) => Self::repository(error), + _ => Self { + kind: MobilePasswordErrorKind::Repository, + title: "Passwords Are Unavailable".to_owned(), + detail: error.to_string(), + }, + } + } + + fn invalid_path() -> Self { + Self { + kind: MobilePasswordErrorKind::InvalidPath, + title: "Invalid Password Folder".to_owned(), + detail: "The password-store path is not valid UTF-8.".to_owned(), + } + } +} + +impl fmt::Display for MobilePasswordError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.title, self.detail) + } +} + +impl Error for MobilePasswordError {} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{MobilePasswordErrorKind, MobilePasswordPage, MobilePasswordRowKind}; + use crate::repository::Repository; + + #[test] + fn locked_pages_preserve_storage_identity_across_nested_refreshes() + -> Result<(), Box> { + let temporary = tempdir()?; + fs::create_dir_all(temporary.path().join("Personal/Finance/Very Deep/Empty"))?; + fs::write( + temporary.path().join("Personal/Finance/bank.gpg"), + b"ciphertext", + )?; + fs::write( + temporary + .path() + .join("Personal/a very long password entry name.gpg"), + b"ciphertext", + )?; + let repository = Repository::open(temporary.path())?; + + let root = MobilePasswordPage::from_repository(&repository, None)?; + assert_eq!(root.title(), "Passwords"); + assert_eq!(root.rows()[0].id(), "directory:Personal"); + assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory); + + let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?; + assert_eq!(finance.id(), "directory:Personal/Finance"); + assert_eq!(finance.title(), "Finance"); + assert_eq!( + finance.rows()[0].id(), + "directory:Personal/Finance/Very Deep" + ); + assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank"); + assert_eq!(finance.rows()[1].detail(), "Locked password"); + + let refreshed = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?; + assert_eq!(finance, refreshed); + let empty = MobilePasswordPage::from_repository( + &repository, + Some("Personal/Finance/Very Deep/Empty"), + )?; + assert!(empty.rows().is_empty()); + + fs::remove_dir(temporary.path().join("Personal/Finance/Very Deep/Empty"))?; + let error = MobilePasswordPage::from_repository( + &repository, + Some("Personal/Finance/Very Deep/Empty"), + ) + .unwrap_err(); + assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing); + Ok(()) + } +} diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 399c499..9fde449 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -386,8 +386,7 @@ impl<'a> VaultReader<'a> { } pub fn list(&self, directory: &DirectoryPath) -> Result { - let snapshot = self.repository.snapshot()?; - build_tree(&snapshot, directory, None) + list_tree(self.repository, directory) } /// Implement explicit or implicit show dispatch. No path means the root tree; a directory @@ -565,6 +564,15 @@ impl<'a> VaultReader<'a> { } } +/// Build a locked navigation model without loading key material or decrypting entries. +pub(crate) fn list_tree( + repository: &Repository, + directory: &DirectoryPath, +) -> Result { + let snapshot = repository.snapshot()?; + build_tree(&snapshot, directory, None) +} + fn build_regex(request: &GrepRequest) -> Result { let pattern = if request.fixed_strings { regex::escape(&request.pattern)