From a748744425ae547ba2aba857de4e68b5e546d938 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 21:43:25 +0200 Subject: [PATCH] Implement iPhone password search --- apple/Generated/ironstorage_apple.swift | 26 ++- apple/Generated/ironstorage_appleFFI.h | 11 ++ apple/Sources/App/IronStorageApp.swift | 217 ++++++++++++++++++++++++ crates/apple/src/lib.rs | 12 +- crates/storage/src/mobile.rs | 18 +- crates/storage/src/mobile_passwords.rs | 117 ++++++++++++- crates/storage/src/read.rs | 2 +- crates/storage/tests/config_contract.rs | 4 + 8 files changed, 397 insertions(+), 10 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index e518cbe..5dffca2 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -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 } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index eed466a..6b35e2c 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -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 diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index b68429c..ae41e32 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -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? + private var shellTask: Task? + 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.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, + 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 diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 1562c87..d25c607 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -60,6 +60,7 @@ uniffi::setup_scaffolding!(); pub enum MobileTab { Home, Passwords, + Search, Totp, Preferences, } @@ -69,6 +70,7 @@ impl From for MobileTab { match tab { StorageTab::Home => Self::Home, StorageTab::Passwords => Self::Passwords, + StorageTab::Search => Self::Search, StorageTab::Totp => Self::Totp, StorageTab::Preferences => Self::Preferences, } @@ -80,6 +82,7 @@ impl From for StorageTab { match tab { MobileTab::Home => Self::Home, MobileTab::Passwords => Self::Passwords, + MobileTab::Search => Self::Search, MobileTab::Totp => Self::Totp, MobileTab::Preferences => Self::Preferences, } @@ -1598,6 +1601,13 @@ pub fn mobile_password_page( .map_err(Into::into) } +#[uniffi::export] +pub fn mobile_password_search(query: String) -> Result { + ironstorage::mobile_passwords::MobilePasswordPage::search(&query) + .map(Into::into) + .map_err(Into::into) +} + #[uniffi::export] pub fn mobile_authentication() -> Result, MobileAuthenticationFfiError> { Ok(Arc::new(MobileAuthentication { @@ -1665,7 +1675,7 @@ mod tests { ] { let shell = super::mobile_shell_fixture(state); assert_eq!(shell.selected_tab, MobileTab::Home); - assert_eq!(shell.pages.len(), 4); + assert_eq!(shell.pages.len(), 5); assert!(shell.pages.iter().all(|page| page.state == state)); } } diff --git a/crates/storage/src/mobile.rs b/crates/storage/src/mobile.rs index d2e83a3..0cc68b2 100644 --- a/crates/storage/src/mobile.rs +++ b/crates/storage/src/mobile.rs @@ -10,17 +10,25 @@ pub enum MobileTab { #[default] Home, Passwords, + Search, Totp, Preferences, } impl MobileTab { - pub const ALL: [Self; 4] = [Self::Home, Self::Passwords, Self::Totp, Self::Preferences]; + pub const ALL: [Self; 5] = [ + Self::Home, + Self::Passwords, + Self::Search, + Self::Totp, + Self::Preferences, + ]; pub const fn title(self) -> &'static str { match self { Self::Home => "Home", Self::Passwords => "Passwords", + Self::Search => "Search", Self::Totp => "TOTP", Self::Preferences => "Preferences", } @@ -30,6 +38,7 @@ impl MobileTab { match self { Self::Home => "house", Self::Passwords => "key", + Self::Search => "magnifyingglass", Self::Totp => "timer", Self::Preferences => "gearshape", } @@ -39,6 +48,7 @@ impl MobileTab { match self { Self::Home => "house.fill", Self::Passwords => "key.fill", + Self::Search => "magnifyingglass", Self::Totp => "timer", Self::Preferences => "gearshape.fill", } @@ -48,6 +58,7 @@ impl MobileTab { match self { Self::Home => "home", Self::Passwords => "passwords", + Self::Search => "search", Self::Totp => "totp", Self::Preferences => "preferences", } @@ -57,6 +68,7 @@ impl MobileTab { match value { "home" => Ok(Self::Home), "passwords" => Ok(Self::Passwords), + "search" => Ok(Self::Search), "totp" => Ok(Self::Totp), "preferences" => Ok(Self::Preferences), _ => Err(ConfigError::InvalidField { @@ -234,7 +246,7 @@ mod tests { use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from}; #[test] - fn every_fixture_is_view_ready_for_all_four_tabs() { + fn every_fixture_is_view_ready_for_all_five_tabs() { for state in [ MobileShellState::Loading, MobileShellState::Empty, @@ -244,7 +256,7 @@ mod tests { ] { let shell = MobileShell::fixture(state); assert_eq!(shell.selected_tab(), MobileTab::Home); - assert_eq!(shell.pages().len(), 4); + assert_eq!(shell.pages().len(), 5); assert_eq!( shell .pages() diff --git a/crates/storage/src/mobile_passwords.rs b/crates/storage/src/mobile_passwords.rs index 41fb487..87ff06f 100644 --- a/crates/storage/src/mobile_passwords.rs +++ b/crates/storage/src/mobile_passwords.rs @@ -4,7 +4,7 @@ use std::{error::Error, fmt}; use crate::{ config::{Config, ConfigError}, - read::{ReadError, TreeNode, TreeNodeKind, list_tree}, + read::{ReadError, TreeNode, TreeNodeKind, hidden_path, list_tree}, repository::{DirectoryPath, Repository, RepositoryError}, }; @@ -66,6 +66,13 @@ impl MobilePasswordPage { Self::from_repository(&repository, path) } + pub fn search(query: &str) -> Result { + let config = Config::load(None).map_err(MobilePasswordError::from_config)?; + let repository = + Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; + Self::search_repository(&repository, query) + } + pub fn id(&self) -> &str { &self.id } @@ -109,6 +116,61 @@ impl MobilePasswordPage { rows: tree.children().iter().map(mobile_row).collect(), }) } + + fn search_repository( + repository: &Repository, + query: &str, + ) -> Result { + let query = query.trim(); + let folded = query.to_lowercase(); + let snapshot = repository + .snapshot() + .map_err(MobilePasswordError::repository)?; + let rows = if folded.is_empty() { + Vec::new() + } else { + snapshot + .entries() + .filter(|entry| !hidden_path(entry.path().as_path())) + .filter_map(|entry| { + let path = entry.path().as_path().to_str()?; + path.to_lowercase() + .contains(&folded) + .then(|| search_row(entry.path(), path)) + }) + .collect() + }; + Ok(Self { + id: format!("search:{query}"), + path: String::new(), + title: "Search".to_owned(), + rows, + }) + } +} + +fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePasswordRow { + let title = path + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(text) + .to_owned(); + let detail = path + .as_path() + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .and_then(|parent| parent.to_str()) + .unwrap_or("Password Store") + .to_owned(); + MobilePasswordRow { + id: format!("entry:{text}"), + path: text.to_owned(), + title, + detail, + system_image: "key.fill".to_owned(), + kind: MobilePasswordRowKind::Entry, + } } fn mobile_row(node: &TreeNode) -> MobilePasswordRow { @@ -283,4 +345,57 @@ mod tests { assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing); Ok(()) } + + #[test] + fn search_matches_entry_and_folder_names_and_preserves_location() + -> Result<(), Box> { + let temporary = tempdir()?; + fs::create_dir_all(temporary.path().join("Personal/Finance"))?; + fs::create_dir_all(temporary.path().join("Work/Finance"))?; + fs::create_dir_all(temporary.path().join(".extensions"))?; + fs::write( + temporary.path().join("Personal/Finance/bank.gpg"), + b"ciphertext", + )?; + fs::write( + temporary.path().join("Work/Finance/bank.gpg"), + b"ciphertext", + )?; + fs::write(temporary.path().join("Personal/mail.gpg"), b"ciphertext")?; + fs::write( + temporary.path().join(".extensions/leaked.gpg"), + b"ciphertext", + )?; + let repository = Repository::open(temporary.path())?; + + let folder = MobilePasswordPage::search_repository(&repository, "FINANCE")?; + assert_eq!( + folder + .rows() + .iter() + .map(|row| (row.title(), row.detail(), row.path())) + .collect::>(), + vec![ + ("bank", "Personal/Finance", "Personal/Finance/bank"), + ("bank", "Work/Finance", "Work/Finance/bank"), + ] + ); + assert!( + MobilePasswordPage::search_repository(&repository, "mail")? + .rows() + .iter() + .any(|row| row.path() == "Personal/mail") + ); + assert!( + MobilePasswordPage::search_repository(&repository, " ")? + .rows() + .is_empty() + ); + assert!( + MobilePasswordPage::search_repository(&repository, "leaked")? + .rows() + .is_empty() + ); + Ok(()) + } } diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 9fde449..90db674 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -715,7 +715,7 @@ fn is_included_path(path: &Path, included: &[&str], directory: bool) -> bool { }) || included.iter().any(|candidate| *candidate == text) } -fn hidden_path(path: &Path) -> bool { +pub(crate) fn hidden_path(path: &Path) -> bool { path.components() .any(|component| component.as_os_str() == EXTENSIONS_DIRECTORY) } diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 27b1ec5..4df40be 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -221,6 +221,10 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"") ); + reloaded.update_mobile_tab(MobileTab::Search)?; + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.mobile_tab(), MobileTab::Search); + fixture.write_explicit(&format!( "{}\n[ui]\nselected_mobile_tab = \"unknown\"\n", fixture.valid_contents()