Implement iPhone password search
This commit is contained in:
@@ -5290,6 +5290,7 @@ public enum MobileTab: Equatable, Hashable {
|
|||||||
|
|
||||||
case home
|
case home
|
||||||
case passwords
|
case passwords
|
||||||
|
case search
|
||||||
case totp
|
case totp
|
||||||
case preferences
|
case preferences
|
||||||
|
|
||||||
@@ -5317,9 +5318,11 @@ public struct FfiConverterTypeMobileTab: FfiConverterRustBuffer {
|
|||||||
|
|
||||||
case 2: return .passwords
|
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
|
default: throw UniffiInternalError.unexpectedEnumCase
|
||||||
}
|
}
|
||||||
@@ -5337,13 +5340,17 @@ public struct FfiConverterTypeMobileTab: FfiConverterRustBuffer {
|
|||||||
writeInt(&buf, Int32(2))
|
writeInt(&buf, Int32(2))
|
||||||
|
|
||||||
|
|
||||||
case .totp:
|
case .search:
|
||||||
writeInt(&buf, Int32(3))
|
writeInt(&buf, Int32(3))
|
||||||
|
|
||||||
|
|
||||||
case .preferences:
|
case .totp:
|
||||||
writeInt(&buf, Int32(4))
|
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 {
|
public func mobileShell() -> MobileShell {
|
||||||
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
|
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -5982,6 +5997,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_func_mobile_password_page() != 64312) {
|
if (uniffi_ironstorage_apple_checksum_func_mobile_password_page() != 64312) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
RustBuffer uniffi_ironstorage_apple_fn_func_mobile_password_page(RustBuffer path, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
|
||||||
#define 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
|
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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_PASSWORD_PAGE
|
||||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_password_page(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
|||||||
let root: UIViewController = switch page.tab {
|
let root: UIViewController = switch page.tab {
|
||||||
case .passwords:
|
case .passwords:
|
||||||
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
||||||
|
case .search:
|
||||||
|
PasswordSearchViewController(shellPage: page, authentication: authentication)
|
||||||
case .totp:
|
case .totp:
|
||||||
TotpListViewController(shellPage: page, authentication: authentication)
|
TotpListViewController(shellPage: page, authentication: authentication)
|
||||||
case .preferences:
|
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
|
@MainActor
|
||||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||||
fileprivate let shellTab = MobileTab.passwords
|
fileprivate let shellTab = MobileTab.passwords
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ uniffi::setup_scaffolding!();
|
|||||||
pub enum MobileTab {
|
pub enum MobileTab {
|
||||||
Home,
|
Home,
|
||||||
Passwords,
|
Passwords,
|
||||||
|
Search,
|
||||||
Totp,
|
Totp,
|
||||||
Preferences,
|
Preferences,
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ impl From<StorageTab> for MobileTab {
|
|||||||
match tab {
|
match tab {
|
||||||
StorageTab::Home => Self::Home,
|
StorageTab::Home => Self::Home,
|
||||||
StorageTab::Passwords => Self::Passwords,
|
StorageTab::Passwords => Self::Passwords,
|
||||||
|
StorageTab::Search => Self::Search,
|
||||||
StorageTab::Totp => Self::Totp,
|
StorageTab::Totp => Self::Totp,
|
||||||
StorageTab::Preferences => Self::Preferences,
|
StorageTab::Preferences => Self::Preferences,
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,7 @@ impl From<MobileTab> for StorageTab {
|
|||||||
match tab {
|
match tab {
|
||||||
MobileTab::Home => Self::Home,
|
MobileTab::Home => Self::Home,
|
||||||
MobileTab::Passwords => Self::Passwords,
|
MobileTab::Passwords => Self::Passwords,
|
||||||
|
MobileTab::Search => Self::Search,
|
||||||
MobileTab::Totp => Self::Totp,
|
MobileTab::Totp => Self::Totp,
|
||||||
MobileTab::Preferences => Self::Preferences,
|
MobileTab::Preferences => Self::Preferences,
|
||||||
}
|
}
|
||||||
@@ -1598,6 +1601,13 @@ pub fn mobile_password_page(
|
|||||||
.map_err(Into::into)
|
.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]
|
#[uniffi::export]
|
||||||
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
||||||
Ok(Arc::new(MobileAuthentication {
|
Ok(Arc::new(MobileAuthentication {
|
||||||
@@ -1665,7 +1675,7 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let shell = super::mobile_shell_fixture(state);
|
let shell = super::mobile_shell_fixture(state);
|
||||||
assert_eq!(shell.selected_tab, MobileTab::Home);
|
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));
|
assert!(shell.pages.iter().all(|page| page.state == state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,25 @@ pub enum MobileTab {
|
|||||||
#[default]
|
#[default]
|
||||||
Home,
|
Home,
|
||||||
Passwords,
|
Passwords,
|
||||||
|
Search,
|
||||||
Totp,
|
Totp,
|
||||||
Preferences,
|
Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileTab {
|
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 {
|
pub const fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Home => "Home",
|
Self::Home => "Home",
|
||||||
Self::Passwords => "Passwords",
|
Self::Passwords => "Passwords",
|
||||||
|
Self::Search => "Search",
|
||||||
Self::Totp => "TOTP",
|
Self::Totp => "TOTP",
|
||||||
Self::Preferences => "Preferences",
|
Self::Preferences => "Preferences",
|
||||||
}
|
}
|
||||||
@@ -30,6 +38,7 @@ impl MobileTab {
|
|||||||
match self {
|
match self {
|
||||||
Self::Home => "house",
|
Self::Home => "house",
|
||||||
Self::Passwords => "key",
|
Self::Passwords => "key",
|
||||||
|
Self::Search => "magnifyingglass",
|
||||||
Self::Totp => "timer",
|
Self::Totp => "timer",
|
||||||
Self::Preferences => "gearshape",
|
Self::Preferences => "gearshape",
|
||||||
}
|
}
|
||||||
@@ -39,6 +48,7 @@ impl MobileTab {
|
|||||||
match self {
|
match self {
|
||||||
Self::Home => "house.fill",
|
Self::Home => "house.fill",
|
||||||
Self::Passwords => "key.fill",
|
Self::Passwords => "key.fill",
|
||||||
|
Self::Search => "magnifyingglass",
|
||||||
Self::Totp => "timer",
|
Self::Totp => "timer",
|
||||||
Self::Preferences => "gearshape.fill",
|
Self::Preferences => "gearshape.fill",
|
||||||
}
|
}
|
||||||
@@ -48,6 +58,7 @@ impl MobileTab {
|
|||||||
match self {
|
match self {
|
||||||
Self::Home => "home",
|
Self::Home => "home",
|
||||||
Self::Passwords => "passwords",
|
Self::Passwords => "passwords",
|
||||||
|
Self::Search => "search",
|
||||||
Self::Totp => "totp",
|
Self::Totp => "totp",
|
||||||
Self::Preferences => "preferences",
|
Self::Preferences => "preferences",
|
||||||
}
|
}
|
||||||
@@ -57,6 +68,7 @@ impl MobileTab {
|
|||||||
match value {
|
match value {
|
||||||
"home" => Ok(Self::Home),
|
"home" => Ok(Self::Home),
|
||||||
"passwords" => Ok(Self::Passwords),
|
"passwords" => Ok(Self::Passwords),
|
||||||
|
"search" => Ok(Self::Search),
|
||||||
"totp" => Ok(Self::Totp),
|
"totp" => Ok(Self::Totp),
|
||||||
"preferences" => Ok(Self::Preferences),
|
"preferences" => Ok(Self::Preferences),
|
||||||
_ => Err(ConfigError::InvalidField {
|
_ => Err(ConfigError::InvalidField {
|
||||||
@@ -234,7 +246,7 @@ mod tests {
|
|||||||
use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from};
|
use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_fixture_is_view_ready_for_all_four_tabs() {
|
fn every_fixture_is_view_ready_for_all_five_tabs() {
|
||||||
for state in [
|
for state in [
|
||||||
MobileShellState::Loading,
|
MobileShellState::Loading,
|
||||||
MobileShellState::Empty,
|
MobileShellState::Empty,
|
||||||
@@ -244,7 +256,7 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let shell = MobileShell::fixture(state);
|
let shell = MobileShell::fixture(state);
|
||||||
assert_eq!(shell.selected_tab(), MobileTab::Home);
|
assert_eq!(shell.selected_tab(), MobileTab::Home);
|
||||||
assert_eq!(shell.pages().len(), 4);
|
assert_eq!(shell.pages().len(), 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shell
|
shell
|
||||||
.pages()
|
.pages()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{error::Error, fmt};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, ConfigError},
|
config::{Config, ConfigError},
|
||||||
read::{ReadError, TreeNode, TreeNodeKind, list_tree},
|
read::{ReadError, TreeNode, TreeNodeKind, hidden_path, list_tree},
|
||||||
repository::{DirectoryPath, Repository, RepositoryError},
|
repository::{DirectoryPath, Repository, RepositoryError},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +66,13 @@ impl MobilePasswordPage {
|
|||||||
Self::from_repository(&repository, path)
|
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 {
|
pub fn id(&self) -> &str {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
@@ -109,6 +116,61 @@ impl MobilePasswordPage {
|
|||||||
rows: tree.children().iter().map(mobile_row).collect(),
|
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 {
|
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||||
@@ -283,4 +345,57 @@ mod tests {
|
|||||||
assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing);
|
assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -715,7 +715,7 @@ fn is_included_path(path: &Path, included: &[&str], directory: bool) -> bool {
|
|||||||
}) || included.iter().any(|candidate| *candidate == text)
|
}) || included.iter().any(|candidate| *candidate == text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hidden_path(path: &Path) -> bool {
|
pub(crate) fn hidden_path(path: &Path) -> bool {
|
||||||
path.components()
|
path.components()
|
||||||
.any(|component| component.as_os_str() == EXTENSIONS_DIRECTORY)
|
.any(|component| component.as_os_str() == EXTENSIONS_DIRECTORY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\"")
|
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!(
|
fixture.write_explicit(&format!(
|
||||||
"{}\n[ui]\nselected_mobile_tab = \"unknown\"\n",
|
"{}\n[ui]\nselected_mobile_tab = \"unknown\"\n",
|
||||||
fixture.valid_contents()
|
fixture.valid_contents()
|
||||||
|
|||||||
Reference in New Issue
Block a user