Navigate password folders on iPhone

This commit is contained in:
2026-08-11 17:30:57 +02:00
parent 8e883f5ccf
commit 3295761bcf
7 changed files with 1144 additions and 5 deletions

View File

@@ -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 struct MobileShell: Equatable, Hashable {
public var selectedTab: MobileTab public var selectedTab: MobileTab
public var pages: [MobilePage] 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 public
enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { 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) #if swift(>=5.8)
@_documentation(visibility: private) @_documentation(visibility: private)
#endif #endif
@@ -2785,6 +3173,31 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer {
return seq 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 { public func mobileHomeOperation() -> MobileHomeOperation {
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
uniffiCallStatus in 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 { public func mobileShell() -> MobileShell {
return try! FfiConverterTypeMobileShell_lift(try! rustCall() { return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
uniffiCallStatus in uniffiCallStatus in
@@ -2862,6 +3283,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) { if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
#define 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 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 #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL

View File

@@ -25,6 +25,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
@MainActor
private protocol MobileTabRoot: AnyObject {
var shellTab: MobileTab { get }
}
@MainActor @MainActor
private final class AppContext: NSObject, UITabBarControllerDelegate { private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController() private let tabs = UITabBarController()
@@ -34,7 +39,9 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
func makeRootController() -> UIViewController { func makeRootController() -> UIViewController {
let shell = mobileShellFixture(state: .loading) let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in 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) let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true navigation.navigationBar.prefersLargeTitles = true
navigation.tabBarItem = UITabBarItem( navigation.tabBarItem = UITabBarItem(
@@ -56,7 +63,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
) { ) {
guard guard
let navigation = viewController as? UINavigationController, let navigation = viewController as? UINavigationController,
let root = navigation.viewControllers.first as? ShellViewController let root = navigation.viewControllers.first as? MobileTabRoot
else { else {
return return
} }
@@ -86,7 +93,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
} }
@MainActor @MainActor
private final class ShellViewController: UITableViewController { private final class ShellViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab: MobileTab fileprivate let shellTab: MobileTab
private var page: MobilePage private var page: MobilePage
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
@@ -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<Void, Never>?
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<MobilePasswordPage, PasswordFailure>.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<MobilePasswordPage, PasswordFailure>,
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 { private struct HomeFailure: Error, Sendable {
let kind: MobileHomeErrorKind let kind: MobileHomeErrorKind
let title: String let title: String

View File

@@ -19,6 +19,11 @@ use ironstorage::{
MobileOnboardingErrorKind as StorageOnboardingErrorKind, MobileOnboardingErrorKind as StorageOnboardingErrorKind,
MobileOnboardingPhase as StorageOnboardingPhase, MobileOnboardingPhase as StorageOnboardingPhase,
}, },
mobile_passwords::{
MobilePasswordError as StoragePasswordError,
MobilePasswordErrorKind as StoragePasswordErrorKind,
MobilePasswordRowKind as StoragePasswordRowKind,
},
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -381,6 +386,111 @@ impl MobileHomeOperation {
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobilePasswordRowKind {
Directory,
Entry,
}
impl From<StoragePasswordRowKind> 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<MobilePasswordRow>,
}
impl From<ironstorage::mobile_passwords::MobilePasswordPage> 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<StoragePasswordErrorKind> 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<StoragePasswordError> 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileOnboardingPhase { pub enum MobileOnboardingPhase {
Validating, Validating,
@@ -592,6 +702,15 @@ pub fn mobile_home_operation() -> Arc<MobileHomeOperation> {
}) })
} }
#[uniffi::export]
pub fn mobile_password_page(
path: Option<String>,
) -> Result<MobilePasswordPage, MobilePasswordFfiError> {
ironstorage::mobile_passwords::MobilePasswordPage::load(path.as_deref())
.map(Into::into)
.map_err(Into::into)
}
#[uniffi::export] #[uniffi::export]
pub fn mobile_onboarding_operation( pub fn mobile_onboarding_operation(
server_url: String, server_url: String,

View File

@@ -17,6 +17,7 @@ pub mod kdbx;
pub mod mobile; pub mod mobile;
pub mod mobile_home; pub mod mobile_home;
pub mod mobile_onboarding; pub mod mobile_onboarding;
pub mod mobile_passwords;
pub mod mutation; pub mod mutation;
pub mod otp; pub mod otp;
pub mod presentation; pub mod presentation;

View File

@@ -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<MobilePasswordRow>,
}
impl MobilePasswordPage {
pub fn load(path: Option<&str>) -> Result<Self, MobilePasswordError> {
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<Self, MobilePasswordError> {
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<dyn std::error::Error>> {
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(())
}
}

View File

@@ -386,8 +386,7 @@ impl<'a> VaultReader<'a> {
} }
pub fn list(&self, directory: &DirectoryPath) -> Result<TreeModel, ReadError> { pub fn list(&self, directory: &DirectoryPath) -> Result<TreeModel, ReadError> {
let snapshot = self.repository.snapshot()?; list_tree(self.repository, directory)
build_tree(&snapshot, directory, None)
} }
/// Implement explicit or implicit show dispatch. No path means the root tree; a 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<TreeModel, ReadError> {
let snapshot = repository.snapshot()?;
build_tree(&snapshot, directory, None)
}
fn build_regex(request: &GrepRequest) -> Result<Regex, ReadError> { fn build_regex(request: &GrepRequest) -> Result<Regex, ReadError> {
let pattern = if request.fixed_strings { let pattern = if request.fixed_strings {
regex::escape(&request.pattern) regex::escape(&request.pattern)