Implement iPhone password search

This commit is contained in:
2026-08-11 21:43:25 +02:00
parent 358ba7d46d
commit a748744425
8 changed files with 397 additions and 10 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<Void, Never>?
private var shellTask: Task<Void, Never>?
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<MobilePasswordPage, PasswordFailure>.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<MobilePasswordPage, PasswordFailure>,
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

View File

@@ -60,6 +60,7 @@ uniffi::setup_scaffolding!();
pub enum MobileTab {
Home,
Passwords,
Search,
Totp,
Preferences,
}
@@ -69,6 +70,7 @@ impl From<StorageTab> 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<MobileTab> 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<MobilePasswordPage, MobilePasswordFfiError> {
ironstorage::mobile_passwords::MobilePasswordPage::search(&query)
.map(Into::into)
.map_err(Into::into)
}
#[uniffi::export]
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, 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));
}
}

View File

@@ -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()

View File

@@ -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<Self, MobilePasswordError> {
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<Self, MobilePasswordError> {
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<dyn std::error::Error>> {
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<_>>(),
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(())
}
}

View File

@@ -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)
}

View File

@@ -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()