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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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\"") 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()