From 87f9e7be98f5719be8f785a1ad1e542457f4a0df Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 12 Aug 2026 20:36:03 +0200 Subject: [PATCH] Add cached TOTP search (#77) --- apple/Generated/ironstorage_apple.swift | 17 ++++- apple/Generated/ironstorage_appleFFI.h | 11 +++ apple/Sources/App/IronStorageApp.swift | 82 +++++++++++++++++++-- crates/apple/src/lib.rs | 10 +++ crates/storage/src/mobile_authentication.rs | 15 +++- crates/storage/src/mobile_totp.rs | 11 +++ crates/storage/tests/mobile_totp.rs | 17 +++++ 7 files changed, 156 insertions(+), 7 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 873863c..0c1d688 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -643,6 +643,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryPage + func searchCachedTotpPage(query: String) throws -> MobileTotpPage? + func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity @@ -928,6 +930,16 @@ open func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])throw }) } +open func searchCachedTotpPage(query: String)throws -> MobileTotpPage? { + return try FfiConverterOptionTypeMobileTotpPage.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page( + self.uniffiCloneHandle(), + FfiConverterString.lower(query),uniffiCallStatus + ) +}) +} + open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -6969,6 +6981,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor() != 30971) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page() != 13608) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } @@ -7067,4 +7082,4 @@ public func uniffiEnsureIronstorageAppleInitialized() { } } -// swiftlint:enable all \ No newline at end of file +// swiftlint:enable all diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 06b5b11..f70b4f6 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -353,6 +353,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status @@ -1054,6 +1059,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_en #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 49023b7..df51ec1 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -1744,17 +1744,23 @@ private final class TotpDiscoveryView: UIView { } @MainActor -private final class TotpListViewController: UITableViewController, MobileTabRoot { +private final class TotpListViewController: UITableViewController, MobileTabRoot, + UISearchResultsUpdating +{ fileprivate let shellTab = MobileTab.totp private let authentication: MobileAuthentication? + private let searchController = UISearchController(searchResultsController: nil) private var shellPage: MobilePage private var page: MobileTotpPage? + private var unfilteredPage: MobileTotpPage? private var loadTask: Task? + private var searchTask: Task? private var unlockTask: Task? private var shellTask: Task? private var progressTask: Task? private var operation: MobileTotpOperation? private var loadGeneration = 0 + private var searchGeneration = 0 private var refreshAfterUnlock = false init(shellPage: MobilePage, authentication: MobileAuthentication?) { @@ -1763,6 +1769,15 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot super.init(style: .insetGrouped) title = shellPage.title navigationItem.largeTitleDisplayMode = .always + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.placeholder = "TOTP entry names and folders" + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.autocorrectionType = .no + searchController.searchBar.spellCheckingType = .no + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + definesPresentationContext = true refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) } @@ -1775,6 +1790,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot deinit { operation?.cancel() loadTask?.cancel() + searchTask?.cancel() unlockTask?.cancel() shellTask?.cancel() progressTask?.cancel() @@ -1809,6 +1825,10 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot reloadShell() } + func updateSearchResults(for searchController: UISearchController) { + search(searchController.searchBar.text ?? "") + } + override func numberOfSections(in tableView: UITableView) -> Int { 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -1912,12 +1932,15 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot @objc private func localStoreDidChange() { operation?.cancel() loadTask?.cancel() + searchTask?.cancel() progressTask?.cancel() loadGeneration += 1 + searchGeneration += 1 operation = nil progressTask = nil tableView.tableHeaderView = nil page = nil + unfilteredPage = nil loadCachedPage() } @@ -1942,7 +1965,10 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot private func refreshState(force: Bool = false) { guard shellPage.state == .ready else { + searchTask?.cancel() + searchGeneration += 1 page = nil + unfilteredPage = nil showUnavailable( title: shellPage.stateTitle, detail: shellPage.stateDetail, @@ -2146,21 +2172,66 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } } - private func apply(_ page: MobileTotpPage) { + private func apply(_ page: MobileTotpPage, remember: Bool = true) { + if remember { + unfilteredPage = page + let query = searchController.searchBar.text ?? "" + if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + search(query) + return + } + } self.page = page configureLockButton() tableView.reloadData() if page.rows.isEmpty { + let query = searchController.searchBar.text ?? "" + let searching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty showUnavailable( - title: "No TOTP Codes", - detail: "No valid time-based OTP entries were found in the password store.", - image: "timer" + title: searching ? "No Results" : "No TOTP Codes", + detail: searching + ? "No cached TOTP entries match “\(query)”." + : "No valid time-based OTP entries were found in the password store.", + image: searching ? "magnifyingglass" : "timer" ) } else { contentUnavailableConfiguration = nil } } + private func search(_ query: String) { + searchGeneration += 1 + let generation = searchGeneration + searchTask?.cancel() + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + if let unfilteredPage { apply(unfilteredPage, remember: false) } + return + } + guard let authentication else { return } + searchTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.searchCachedTotpPage(query: query) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self, generation == searchGeneration else { return } + switch result { + case let .success(.some(page)): + apply(page, remember: false) + case .success(.none): + break + case let .failure(failure): + handle(failure) + } + } + } + private func configureLockButton() { let unlocked = (try? authentication?.state().unlocked) == true navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -2175,6 +2246,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot private func showLocked() { page = nil + unfilteredPage = nil tableView.reloadData() navigationItem.rightBarButtonItem = nil var configuration = UIContentUnavailableConfiguration.empty() diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index e1931fb..1e9b4dd 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -1494,6 +1494,16 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn search_cached_totp_page( + &self, + query: String, + ) -> Result, MobileAuthenticationFfiError> { + self.authentication + .search_cached_totp_page(&query) + .map(|page| page.map(Into::into)) + .map_err(Into::into) + } + pub fn totp_detail( &self, path: String, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 51326ff..94b64bb 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -587,9 +587,22 @@ impl MobileAuthentication { } pub fn cached_totp_page(&self) -> Result, MobileAuthenticationError> { + self.search_cached_totp_page("") + } + + pub fn search_cached_totp_page( + &self, + query: &str, + ) -> Result, MobileAuthenticationError> { let shared = self.status()?.watch_shared_totp_entries.clone(); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); - Ok(MobileTotpService::new(&self.repository, &self.keys).cached_page(&shared, &cache_path)) + Ok( + MobileTotpService::new(&self.repository, &self.keys).search_cached_page( + &shared, + &cache_path, + query, + ), + ) } pub fn totp_detail( diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index cac1bc6..3929403 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -423,14 +423,25 @@ impl<'a> MobileTotpService<'a> { &self, shared: &BTreeSet, cache_path: &Path, + ) -> Option { + self.search_cached_page(shared, cache_path, "") + } + + pub fn search_cached_page( + &self, + shared: &BTreeSet, + cache_path: &Path, + query: &str, ) -> Option { let store = store_identity(self.repository.root_path(), cache_path); let (catalog, _) = load_cache(cache_path, &store); + let query = query.trim().to_lowercase(); let mut rows: Vec<_> = catalog? .entries .into_iter() .filter(|(_, record)| record.is_totp) .filter_map(|(path, _)| EntryPath::parse(&path).ok()) + .filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query)) .map(|path| cached_row(&path, shared.contains(&path))) .collect(); sort_rows(&mut rows); diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index f2f493f..be8287c 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -233,6 +233,23 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu .rows(), page.rows() ); + assert_eq!( + service + .search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ") + .expect("created cache") + .rows() + .iter() + .map(|row| row.path()) + .collect::>(), + ["team-a/shared", "team-a"] + ); + assert!( + service + .search_cached_page(&BTreeSet::new(), &cache, "missing") + .expect("created cache") + .rows() + .is_empty() + ); write_plaintext( &repository,