Implement iPhone password search

This commit is contained in:
2026-08-11 21:43:25 +02:00
parent 358ba7d46d
commit a748744425
8 changed files with 397 additions and 10 deletions

View File

@@ -5290,6 +5290,7 @@ public enum MobileTab: Equatable, Hashable {
case home
case passwords
case search
case totp
case preferences
@@ -5317,9 +5318,11 @@ public struct FfiConverterTypeMobileTab: FfiConverterRustBuffer {
case 2: return .passwords
case 3: return .totp
case 3: return .search
case 4: return .preferences
case 4: return .totp
case 5: return .preferences
default: throw UniffiInternalError.unexpectedEnumCase
}
@@ -5337,13 +5340,17 @@ public struct FfiConverterTypeMobileTab: FfiConverterRustBuffer {
writeInt(&buf, Int32(2))
case .totp:
case .search:
writeInt(&buf, Int32(3))
case .preferences:
case .totp:
writeInt(&buf, Int32(4))
case .preferences:
writeInt(&buf, Int32(5))
}
}
}
@@ -5914,6 +5921,14 @@ public func mobilePasswordPage(path: String?)throws -> MobilePasswordPage {
)
})
}
public func mobilePasswordSearch(query: String)throws -> MobilePasswordPage {
return try FfiConverterTypeMobilePasswordPage_lift(try rustCallWithError(FfiConverterTypeMobilePasswordFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_func_mobile_password_search(
FfiConverterString.lower(query),uniffiCallStatus
)
})
}
public func mobileShell() -> MobileShell {
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
uniffiCallStatus in
@@ -5982,6 +5997,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_mobile_password_page() != 64312) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_func_mobile_password_search() != 23429) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
return InitializationResult.apiChecksumMismatch
}

View File

@@ -521,6 +521,11 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(RustBuffer
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_PASSWORD_SEARCH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_PASSWORD_SEARCH
RustBuffer uniffi_ironstorage_apple_fn_func_mobile_password_search(RustBuffer query, 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
@@ -836,6 +841,12 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation(void
#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_PASSWORD_SEARCH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_PASSWORD_SEARCH
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_password_search(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL

View File

@@ -87,6 +87,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
let root: UIViewController = switch page.tab {
case .passwords:
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
case .search:
PasswordSearchViewController(shellPage: page, authentication: authentication)
case .totp:
TotpListViewController(shellPage: page, authentication: authentication)
case .preferences:
@@ -2307,6 +2309,221 @@ private extension UInt64 {
}
}
@MainActor
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
{
fileprivate let shellTab = MobileTab.search
private let authentication: MobileAuthentication?
private let searchController = UISearchController(searchResultsController: nil)
private var shellPage: MobilePage
private var searchPage: MobilePasswordPage?
private var searchTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>?
private var generation = 0
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
self.authentication = authentication
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = .always
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "Entry names and folders"
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.autocorrectionType = .no
searchController.searchBar.spellCheckingType = .no
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
searchTask?.cancel()
shellTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
apply(shellPage)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if searchController.searchBar.text?.isEmpty != false {
searchController.isActive = true
searchController.searchBar.becomeFirstResponder()
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
searchPage?.rows.isEmpty == false ? 1 : 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchPage?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let row = searchPage?.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 = 2
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
cell.accessibilityHint = "Opens the locked password viewer."
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = searchPage?.rows[indexPath.row] else { return }
navigationController?.pushViewController(
LockedPasswordViewController(entry: row, authentication: authentication),
animated: true
)
}
func updateSearchResults(for searchController: UISearchController) {
search(searchController.searchBar.text ?? "")
}
@objc private func localStoreDidChange() {
guard viewIfLoaded?.window != nil else { return }
search(searchController.searchBar.text ?? "")
}
private func reloadShell() {
shellTask?.cancel()
shellTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
let page = shell.pages.first(where: { $0.tab == .search })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
shellPage = page
title = page.title
guard page.state == .ready else {
searchTask?.cancel()
searchPage = nil
tableView.reloadData()
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
}
search(searchController.searchBar.text ?? "")
}
private func search(_ query: String) {
generation += 1
let currentGeneration = generation
searchTask?.cancel()
guard shellPage.state == .ready else { return }
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "Search Passwords"
configuration.secondaryText = "Search by entry name or folder."
contentUnavailableConfiguration = configuration
return
}
searchPage = nil
tableView.reloadData()
var loading = UIContentUnavailableConfiguration.loading()
loading.text = "Searching Passwords"
contentUnavailableConfiguration = loading
searchTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobilePasswordPage, PasswordFailure>.success(
try mobilePasswordSearch(query: query)
)
} catch let error as MobilePasswordFfiError {
return .failure(PasswordFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self, currentGeneration == generation else { return }
finish(result, query: query)
}
}
private func finish(
_ result: Result<MobilePasswordPage, PasswordFailure>,
query: String
) {
switch result {
case let .success(page):
searchPage = page
tableView.reloadData()
if page.rows.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "No Results"
configuration.secondaryText = "No password entries match “\(query)”."
contentUnavailableConfiguration = configuration
} else {
contentUnavailableConfiguration = nil
UIAccessibility.post(
notification: .announcement,
argument: "\(page.rows.count) password results"
)
}
case let .failure(failure):
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
contentUnavailableConfiguration = configuration
}
}
}
@MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords