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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user