Build native iPhone application shell
This commit is contained in:
@@ -3,17 +3,181 @@
|
||||
|
||||
//! Mechanical UniFFI exports for Apple presentation code.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigError,
|
||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileTab {
|
||||
Home,
|
||||
Passwords,
|
||||
Totp,
|
||||
Preferences,
|
||||
}
|
||||
|
||||
impl From<StorageTab> for MobileTab {
|
||||
fn from(tab: StorageTab) -> Self {
|
||||
match tab {
|
||||
StorageTab::Home => Self::Home,
|
||||
StorageTab::Passwords => Self::Passwords,
|
||||
StorageTab::Totp => Self::Totp,
|
||||
StorageTab::Preferences => Self::Preferences,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MobileTab> for StorageTab {
|
||||
fn from(tab: MobileTab) -> Self {
|
||||
match tab {
|
||||
MobileTab::Home => Self::Home,
|
||||
MobileTab::Passwords => Self::Passwords,
|
||||
MobileTab::Totp => Self::Totp,
|
||||
MobileTab::Preferences => Self::Preferences,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileShellState {
|
||||
Loading,
|
||||
Empty,
|
||||
Ready,
|
||||
Locked,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl From<StorageShellState> for MobileShellState {
|
||||
fn from(state: StorageShellState) -> Self {
|
||||
match state {
|
||||
StorageShellState::Loading => Self::Loading,
|
||||
StorageShellState::Empty => Self::Empty,
|
||||
StorageShellState::Ready => Self::Ready,
|
||||
StorageShellState::Locked => Self::Locked,
|
||||
StorageShellState::Error => Self::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MobileShellState> for StorageShellState {
|
||||
fn from(state: MobileShellState) -> Self {
|
||||
match state {
|
||||
MobileShellState::Loading => Self::Loading,
|
||||
MobileShellState::Empty => Self::Empty,
|
||||
MobileShellState::Ready => Self::Ready,
|
||||
MobileShellState::Locked => Self::Locked,
|
||||
MobileShellState::Error => Self::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePage {
|
||||
pub tab: MobileTab,
|
||||
pub title: String,
|
||||
pub system_image: String,
|
||||
pub selected_system_image: String,
|
||||
pub state: MobileShellState,
|
||||
pub state_title: String,
|
||||
pub state_detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileShell {
|
||||
pub selected_tab: MobileTab,
|
||||
pub pages: Vec<MobilePage>,
|
||||
}
|
||||
|
||||
impl From<mobile::MobileShell> for MobileShell {
|
||||
fn from(shell: mobile::MobileShell) -> Self {
|
||||
Self {
|
||||
selected_tab: shell.selected_tab().into(),
|
||||
pages: shell
|
||||
.pages()
|
||||
.iter()
|
||||
.map(|page| MobilePage {
|
||||
tab: page.tab().into(),
|
||||
title: page.title().to_owned(),
|
||||
system_image: page.system_image().to_owned(),
|
||||
selected_system_image: page.selected_system_image().to_owned(),
|
||||
state: page.state().into(),
|
||||
state_title: page.state_title().to_owned(),
|
||||
state_detail: page.state_detail().to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobilePreferenceError {
|
||||
Configuration { message: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for MobilePreferenceError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Configuration { message } => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobilePreferenceError {}
|
||||
|
||||
impl From<ConfigError> for MobilePreferenceError {
|
||||
fn from(error: ConfigError) -> Self {
|
||||
Self::Configuration {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn product_name() -> String {
|
||||
ironstorage::PRODUCT_NAME.to_owned()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_shell() -> MobileShell {
|
||||
mobile::MobileShell::load().into()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_shell_fixture(state: MobileShellState) -> MobileShell {
|
||||
mobile::MobileShell::fixture(state.into()).into()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceError> {
|
||||
mobile::store_selected_tab(tab.into()).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MobileShellState, MobileTab};
|
||||
|
||||
#[test]
|
||||
fn bridge_reads_product_name_from_storage_crate() {
|
||||
assert_eq!(super::product_name(), ironstorage::PRODUCT_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_exposes_every_view_ready_shell_fixture() {
|
||||
for state in [
|
||||
MobileShellState::Loading,
|
||||
MobileShellState::Empty,
|
||||
MobileShellState::Ready,
|
||||
MobileShellState::Locked,
|
||||
MobileShellState::Error,
|
||||
] {
|
||||
let shell = super::mobile_shell_fixture(state);
|
||||
assert_eq!(shell.selected_tab, MobileTab::Home);
|
||||
assert_eq!(shell.pages.len(), 4);
|
||||
assert!(shell.pages.iter().all(|page| page.state == state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT};
|
||||
use crate::mobile::MobileTab;
|
||||
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
|
||||
|
||||
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
||||
@@ -33,6 +34,7 @@ pub struct Config {
|
||||
editor: Option<EditorCommand>,
|
||||
clipboard_timeout: ClipboardTimeout,
|
||||
authentication_timeout: AuthenticationTimeout,
|
||||
mobile_tab: MobileTab,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
}
|
||||
|
||||
@@ -118,6 +120,10 @@ impl Config {
|
||||
self.authentication_timeout
|
||||
}
|
||||
|
||||
pub fn mobile_tab(&self) -> MobileTab {
|
||||
self.mobile_tab
|
||||
}
|
||||
|
||||
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||
&self.git_remotes
|
||||
}
|
||||
@@ -136,6 +142,31 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> {
|
||||
let mut document = self.document.clone();
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let ui = root
|
||||
.entry("ui")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "ui" })?;
|
||||
ui.insert(
|
||||
"selected_mobile_tab".to_owned(),
|
||||
toml::Value::String(tab.config_value().to_owned()),
|
||||
);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
/// Select a configured remote by name, or the configured default (first
|
||||
/// remote) when no name was requested.
|
||||
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
|
||||
@@ -605,6 +636,8 @@ struct RawConfig {
|
||||
#[serde(default)]
|
||||
security: RawSecurity,
|
||||
#[serde(default)]
|
||||
ui: RawUi,
|
||||
#[serde(default)]
|
||||
git: RawGit,
|
||||
}
|
||||
|
||||
@@ -614,6 +647,12 @@ struct RawSecurity {
|
||||
inactivity_timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawUi {
|
||||
selected_mobile_tab: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawEditor {
|
||||
@@ -686,6 +725,13 @@ fn validate_config(
|
||||
.map_err(|_| ConfigError::InvalidField {
|
||||
field: "security.inactivity_timeout_seconds",
|
||||
})?;
|
||||
let mobile_tab = raw
|
||||
.ui
|
||||
.selected_mobile_tab
|
||||
.as_deref()
|
||||
.map(MobileTab::from_config)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||
|
||||
Ok(Config {
|
||||
@@ -697,6 +743,7 @@ fn validate_config(
|
||||
editor,
|
||||
clipboard_timeout,
|
||||
authentication_timeout,
|
||||
mobile_tab,
|
||||
git_remotes,
|
||||
})
|
||||
}
|
||||
@@ -840,6 +887,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
"editor",
|
||||
"clipboard_timeout_seconds",
|
||||
"security",
|
||||
"ui",
|
||||
"git",
|
||||
],
|
||||
)?;
|
||||
@@ -849,6 +897,12 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
})?;
|
||||
validate_table(security, "security", &["inactivity_timeout_seconds"])?;
|
||||
}
|
||||
if let Some(ui) = root.get("ui") {
|
||||
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||
path: source.to_owned(),
|
||||
})?;
|
||||
validate_table(ui, "ui", &["selected_mobile_tab"])?;
|
||||
}
|
||||
let Some(git) = root.get("git") else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod document;
|
||||
pub mod generate;
|
||||
pub mod git;
|
||||
pub mod kdbx;
|
||||
pub mod mobile;
|
||||
pub mod mutation;
|
||||
pub mod otp;
|
||||
pub mod presentation;
|
||||
|
||||
279
crates/storage/src/mobile.rs
Normal file
279
crates/storage/src/mobile.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
//! View-ready state for native mobile presentation shells.
|
||||
|
||||
use crate::{
|
||||
config::{Config, ConfigError},
|
||||
repository::{Repository, RepositoryError},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum MobileTab {
|
||||
#[default]
|
||||
Home,
|
||||
Passwords,
|
||||
Totp,
|
||||
Preferences,
|
||||
}
|
||||
|
||||
impl MobileTab {
|
||||
pub const ALL: [Self; 4] = [Self::Home, Self::Passwords, Self::Totp, Self::Preferences];
|
||||
|
||||
pub const fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "Home",
|
||||
Self::Passwords => "Passwords",
|
||||
Self::Totp => "TOTP",
|
||||
Self::Preferences => "Preferences",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn system_image(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "house",
|
||||
Self::Passwords => "key",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn selected_system_image(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "house.fill",
|
||||
Self::Passwords => "key.fill",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape.fill",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn config_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "home",
|
||||
Self::Passwords => "passwords",
|
||||
Self::Totp => "totp",
|
||||
Self::Preferences => "preferences",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_config(value: &str) -> Result<Self, ConfigError> {
|
||||
match value {
|
||||
"home" => Ok(Self::Home),
|
||||
"passwords" => Ok(Self::Passwords),
|
||||
"totp" => Ok(Self::Totp),
|
||||
"preferences" => Ok(Self::Preferences),
|
||||
_ => Err(ConfigError::InvalidField {
|
||||
field: "ui.selected_mobile_tab",
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileShellState {
|
||||
Loading,
|
||||
Empty,
|
||||
Ready,
|
||||
Locked,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobilePage {
|
||||
tab: MobileTab,
|
||||
title: String,
|
||||
system_image: String,
|
||||
selected_system_image: String,
|
||||
state: MobileShellState,
|
||||
state_title: String,
|
||||
state_detail: String,
|
||||
}
|
||||
|
||||
impl MobilePage {
|
||||
pub fn tab(&self) -> MobileTab {
|
||||
self.tab
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn selected_system_image(&self) -> &str {
|
||||
&self.selected_system_image
|
||||
}
|
||||
|
||||
pub fn state(&self) -> MobileShellState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn state_title(&self) -> &str {
|
||||
&self.state_title
|
||||
}
|
||||
|
||||
pub fn state_detail(&self) -> &str {
|
||||
&self.state_detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileShell {
|
||||
selected_tab: MobileTab,
|
||||
pages: Vec<MobilePage>,
|
||||
}
|
||||
|
||||
impl MobileShell {
|
||||
pub fn load() -> Self {
|
||||
match Config::load(None) {
|
||||
Ok(config) => match Repository::open(config.vault()) {
|
||||
Ok(_) => Self::new(config.mobile_tab(), MobileShellState::Ready, None),
|
||||
Err(RepositoryError::Io {
|
||||
source: std::io::ErrorKind::NotFound,
|
||||
..
|
||||
}) => Self::new(config.mobile_tab(), MobileShellState::Empty, None),
|
||||
Err(error) => Self::new(
|
||||
config.mobile_tab(),
|
||||
MobileShellState::Error,
|
||||
Some(format!("Password store could not be opened: {error}")),
|
||||
),
|
||||
},
|
||||
Err(ConfigError::NotFound { .. }) => {
|
||||
Self::new(MobileTab::Home, MobileShellState::Empty, None)
|
||||
}
|
||||
Err(error) => Self::new(
|
||||
MobileTab::Home,
|
||||
MobileShellState::Error,
|
||||
Some(format!("Configuration could not be loaded: {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fixture(state: MobileShellState) -> Self {
|
||||
Self::new(MobileTab::Home, state, None)
|
||||
}
|
||||
|
||||
pub fn selected_tab(&self) -> MobileTab {
|
||||
self.selected_tab
|
||||
}
|
||||
|
||||
pub fn pages(&self) -> &[MobilePage] {
|
||||
&self.pages
|
||||
}
|
||||
|
||||
fn new(selected_tab: MobileTab, state: MobileShellState, error: Option<String>) -> Self {
|
||||
let pages = MobileTab::ALL
|
||||
.into_iter()
|
||||
.map(|tab| {
|
||||
let (state_title, state_detail) = state_copy(tab, state, error.as_deref());
|
||||
MobilePage {
|
||||
tab,
|
||||
title: tab.title().to_owned(),
|
||||
system_image: tab.system_image().to_owned(),
|
||||
selected_system_image: tab.selected_system_image().to_owned(),
|
||||
state,
|
||||
state_title,
|
||||
state_detail,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
selected_tab,
|
||||
pages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store_selected_tab(tab: MobileTab) -> Result<(), ConfigError> {
|
||||
store_selected_tab_from(Config::load(None), tab)
|
||||
}
|
||||
|
||||
fn store_selected_tab_from(
|
||||
config: Result<Config, ConfigError>,
|
||||
tab: MobileTab,
|
||||
) -> Result<(), ConfigError> {
|
||||
match config {
|
||||
Ok(config) => config.update_mobile_tab(tab),
|
||||
Err(ConfigError::NotFound { .. }) => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_copy(tab: MobileTab, state: MobileShellState, error: Option<&str>) -> (String, String) {
|
||||
match state {
|
||||
MobileShellState::Loading => (
|
||||
format!("Loading {}", tab.title()),
|
||||
"Preparing the password-store presentation.".to_owned(),
|
||||
),
|
||||
MobileShellState::Empty => (
|
||||
"No Password Store".to_owned(),
|
||||
"Set up a local store to use IronStorage.".to_owned(),
|
||||
),
|
||||
MobileShellState::Ready => (
|
||||
tab.title().to_owned(),
|
||||
"The password store is ready.".to_owned(),
|
||||
),
|
||||
MobileShellState::Locked => (
|
||||
"IronStorage is Locked".to_owned(),
|
||||
"Authenticate to reveal protected content.".to_owned(),
|
||||
),
|
||||
MobileShellState::Error => (
|
||||
"IronStorage Is Unavailable".to_owned(),
|
||||
error
|
||||
.unwrap_or("The password-store state could not be loaded.")
|
||||
.to_owned(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::ConfigError;
|
||||
|
||||
use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from};
|
||||
|
||||
#[test]
|
||||
fn every_fixture_is_view_ready_for_all_four_tabs() {
|
||||
for state in [
|
||||
MobileShellState::Loading,
|
||||
MobileShellState::Empty,
|
||||
MobileShellState::Ready,
|
||||
MobileShellState::Locked,
|
||||
MobileShellState::Error,
|
||||
] {
|
||||
let shell = MobileShell::fixture(state);
|
||||
assert_eq!(shell.selected_tab(), MobileTab::Home);
|
||||
assert_eq!(shell.pages().len(), 4);
|
||||
assert_eq!(
|
||||
shell
|
||||
.pages()
|
||||
.iter()
|
||||
.map(MobilePage::tab)
|
||||
.collect::<Vec<_>>(),
|
||||
MobileTab::ALL
|
||||
);
|
||||
assert!(shell.pages().iter().all(|page| {
|
||||
page.state() == state
|
||||
&& !page.title().is_empty()
|
||||
&& !page.system_image().is_empty()
|
||||
&& !page.selected_system_image().is_empty()
|
||||
&& !page.state_title().is_empty()
|
||||
&& !page.state_detail().is_empty()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_selection_before_onboarding_is_a_supported_no_op() {
|
||||
assert_eq!(
|
||||
store_selected_tab_from(
|
||||
Err(ConfigError::NotFound {
|
||||
path: PathBuf::from("config.toml"),
|
||||
}),
|
||||
MobileTab::Preferences,
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use ironstorage::{
|
||||
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
|
||||
config::{ConfigError, ConfigLoader, EditorSource},
|
||||
desktop::DesktopStorage,
|
||||
mobile::MobileTab,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -178,6 +179,37 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||
fixture.write_explicit(fixture.valid_contents())?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(config.mobile_tab(), MobileTab::Home);
|
||||
|
||||
config.update_mobile_tab(MobileTab::Totp)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.mobile_tab(), MobileTab::Totp);
|
||||
assert!(
|
||||
fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"")
|
||||
);
|
||||
|
||||
fixture.write_explicit(&format!(
|
||||
"{}\n[ui]\nselected_mobile_tab = \"unknown\"\n",
|
||||
fixture.valid_contents()
|
||||
))?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(&fixture.explicit_path()))
|
||||
.expect_err("unknown mobile tab"),
|
||||
ConfigError::InvalidField {
|
||||
field: "ui.selected_mobile_tab"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
|
||||
Reference in New Issue
Block a user