Add cached TOTP search (#77)

This commit is contained in:
2026-08-12 20:36:03 +02:00
parent 1ce7b7814b
commit 87f9e7be98
7 changed files with 156 additions and 7 deletions

View File

@@ -643,6 +643,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryPage func saveEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryPage
func searchCachedTotpPage(query: String) throws -> MobileTotpPage?
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity 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 { open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -6969,6 +6981,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor() != 30971) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor() != 30971) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -7067,4 +7082,4 @@ public func uniffiEnsureIronstorageAppleInitialized() {
} }
} }
// swiftlint:enable all // swiftlint:enable all

View File

@@ -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 RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
); );
#endif #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 #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
#define 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 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 #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK

View File

@@ -1744,17 +1744,23 @@ private final class TotpDiscoveryView: UIView {
} }
@MainActor @MainActor
private final class TotpListViewController: UITableViewController, MobileTabRoot { private final class TotpListViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
{
fileprivate let shellTab = MobileTab.totp fileprivate let shellTab = MobileTab.totp
private let authentication: MobileAuthentication? private let authentication: MobileAuthentication?
private let searchController = UISearchController(searchResultsController: nil)
private var shellPage: MobilePage private var shellPage: MobilePage
private var page: MobileTotpPage? private var page: MobileTotpPage?
private var unfilteredPage: MobileTotpPage?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var searchTask: Task<Void, Never>?
private var unlockTask: Task<Void, Never>? private var unlockTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>? private var shellTask: Task<Void, Never>?
private var progressTask: Task<Void, Never>? private var progressTask: Task<Void, Never>?
private var operation: MobileTotpOperation? private var operation: MobileTotpOperation?
private var loadGeneration = 0 private var loadGeneration = 0
private var searchGeneration = 0
private var refreshAfterUnlock = false private var refreshAfterUnlock = false
init(shellPage: MobilePage, authentication: MobileAuthentication?) { init(shellPage: MobilePage, authentication: MobileAuthentication?) {
@@ -1763,6 +1769,15 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
super.init(style: .insetGrouped) super.init(style: .insetGrouped)
title = shellPage.title title = shellPage.title
navigationItem.largeTitleDisplayMode = .always 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 = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
} }
@@ -1775,6 +1790,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
deinit { deinit {
operation?.cancel() operation?.cancel()
loadTask?.cancel() loadTask?.cancel()
searchTask?.cancel()
unlockTask?.cancel() unlockTask?.cancel()
shellTask?.cancel() shellTask?.cancel()
progressTask?.cancel() progressTask?.cancel()
@@ -1809,6 +1825,10 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
reloadShell() reloadShell()
} }
func updateSearchResults(for searchController: UISearchController) {
search(searchController.searchBar.text ?? "")
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 } override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
@@ -1912,12 +1932,15 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
@objc private func localStoreDidChange() { @objc private func localStoreDidChange() {
operation?.cancel() operation?.cancel()
loadTask?.cancel() loadTask?.cancel()
searchTask?.cancel()
progressTask?.cancel() progressTask?.cancel()
loadGeneration += 1 loadGeneration += 1
searchGeneration += 1
operation = nil operation = nil
progressTask = nil progressTask = nil
tableView.tableHeaderView = nil tableView.tableHeaderView = nil
page = nil page = nil
unfilteredPage = nil
loadCachedPage() loadCachedPage()
} }
@@ -1942,7 +1965,10 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
private func refreshState(force: Bool = false) { private func refreshState(force: Bool = false) {
guard shellPage.state == .ready else { guard shellPage.state == .ready else {
searchTask?.cancel()
searchGeneration += 1
page = nil page = nil
unfilteredPage = nil
showUnavailable( showUnavailable(
title: shellPage.stateTitle, title: shellPage.stateTitle,
detail: shellPage.stateDetail, 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 self.page = page
configureLockButton() configureLockButton()
tableView.reloadData() tableView.reloadData()
if page.rows.isEmpty { if page.rows.isEmpty {
let query = searchController.searchBar.text ?? ""
let searching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
showUnavailable( showUnavailable(
title: "No TOTP Codes", title: searching ? "No Results" : "No TOTP Codes",
detail: "No valid time-based OTP entries were found in the password store.", detail: searching
image: "timer" ? "No cached TOTP entries match “\(query)”."
: "No valid time-based OTP entries were found in the password store.",
image: searching ? "magnifyingglass" : "timer"
) )
} else { } else {
contentUnavailableConfiguration = nil 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<MobileTotpPage?, AuthenticationFailure>.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() { private func configureLockButton() {
let unlocked = (try? authentication?.state().unlocked) == true let unlocked = (try? authentication?.state().unlocked) == true
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -2175,6 +2246,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
private func showLocked() { private func showLocked() {
page = nil page = nil
unfilteredPage = nil
tableView.reloadData() tableView.reloadData()
navigationItem.rightBarButtonItem = nil navigationItem.rightBarButtonItem = nil
var configuration = UIContentUnavailableConfiguration.empty() var configuration = UIContentUnavailableConfiguration.empty()

View File

@@ -1494,6 +1494,16 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn search_cached_totp_page(
&self,
query: String,
) -> Result<Option<MobileTotpPage>, MobileAuthenticationFfiError> {
self.authentication
.search_cached_totp_page(&query)
.map(|page| page.map(Into::into))
.map_err(Into::into)
}
pub fn totp_detail( pub fn totp_detail(
&self, &self,
path: String, path: String,

View File

@@ -587,9 +587,22 @@ impl MobileAuthentication {
} }
pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> { pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
self.search_cached_totp_page("")
}
pub fn search_cached_totp_page(
&self,
query: &str,
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
let shared = self.status()?.watch_shared_totp_entries.clone(); let shared = self.status()?.watch_shared_totp_entries.clone();
let cache_path = self.config.source().with_file_name("totp-catalog.toml"); 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( pub fn totp_detail(

View File

@@ -423,14 +423,25 @@ impl<'a> MobileTotpService<'a> {
&self, &self,
shared: &BTreeSet<EntryPath>, shared: &BTreeSet<EntryPath>,
cache_path: &Path, cache_path: &Path,
) -> Option<MobileTotpPage> {
self.search_cached_page(shared, cache_path, "")
}
pub fn search_cached_page(
&self,
shared: &BTreeSet<EntryPath>,
cache_path: &Path,
query: &str,
) -> Option<MobileTotpPage> { ) -> Option<MobileTotpPage> {
let store = store_identity(self.repository.root_path(), cache_path); let store = store_identity(self.repository.root_path(), cache_path);
let (catalog, _) = load_cache(cache_path, &store); let (catalog, _) = load_cache(cache_path, &store);
let query = query.trim().to_lowercase();
let mut rows: Vec<_> = catalog? let mut rows: Vec<_> = catalog?
.entries .entries
.into_iter() .into_iter()
.filter(|(_, record)| record.is_totp) .filter(|(_, record)| record.is_totp)
.filter_map(|(path, _)| EntryPath::parse(&path).ok()) .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))) .map(|path| cached_row(&path, shared.contains(&path)))
.collect(); .collect();
sort_rows(&mut rows); sort_rows(&mut rows);

View File

@@ -233,6 +233,23 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
.rows(), .rows(),
page.rows() page.rows()
); );
assert_eq!(
service
.search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ")
.expect("created cache")
.rows()
.iter()
.map(|row| row.path())
.collect::<Vec<_>>(),
["team-a/shared", "team-a"]
);
assert!(
service
.search_cached_page(&BTreeSet::new(), &cache, "missing")
.expect("created cache")
.rows()
.is_empty()
);
write_plaintext( write_plaintext(
&repository, &repository,