Navigate password folders on iPhone
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
let kind: MobileHomeErrorKind
|
||||
let title: String
|
||||
|
||||
@@ -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<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)]
|
||||
pub enum MobileOnboardingPhase {
|
||||
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]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
|
||||
@@ -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;
|
||||
|
||||
286
crates/storage/src/mobile_passwords.rs
Normal file
286
crates/storage/src/mobile_passwords.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -386,8 +386,7 @@ impl<'a> VaultReader<'a> {
|
||||
}
|
||||
|
||||
pub fn list(&self, directory: &DirectoryPath) -> Result<TreeModel, ReadError> {
|
||||
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<TreeModel, ReadError> {
|
||||
let snapshot = repository.snapshot()?;
|
||||
build_tree(&snapshot, directory, None)
|
||||
}
|
||||
|
||||
fn build_regex(request: &GrepRequest) -> Result<Regex, ReadError> {
|
||||
let pattern = if request.fixed_strings {
|
||||
regex::escape(&request.pattern)
|
||||
|
||||
Reference in New Issue
Block a user