Add cached TOTP search (#77)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
private var shellTask: Task<Void, Never>?
|
||||
private var progressTask: Task<Void, Never>?
|
||||
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<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() {
|
||||
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()
|
||||
|
||||
@@ -1494,6 +1494,16 @@ impl MobileAuthentication {
|
||||
.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(
|
||||
&self,
|
||||
path: String,
|
||||
|
||||
@@ -587,9 +587,22 @@ impl MobileAuthentication {
|
||||
}
|
||||
|
||||
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 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(
|
||||
|
||||
@@ -423,14 +423,25 @@ impl<'a> MobileTotpService<'a> {
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
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> {
|
||||
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);
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
["team-a/shared", "team-a"]
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.search_cached_page(&BTreeSet::new(), &cache, "missing")
|
||||
.expect("created cache")
|
||||
.rows()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
write_plaintext(
|
||||
&repository,
|
||||
|
||||
Reference in New Issue
Block a user