Implement desktop help settings and about

This commit is contained in:
2026-08-10 18:10:01 +02:00
parent 7c64be3a45
commit 2eef49c5c3
4 changed files with 428 additions and 58 deletions

View File

@@ -236,7 +236,13 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
pub fn enabled(action: UiAction, context: ActionContext) -> bool { pub fn enabled(action: UiAction, context: ActionContext) -> bool {
match action { match action {
UiAction::About | UiAction::Settings | UiAction::Help => true, UiAction::About | UiAction::Help => !context.modal_open,
UiAction::Settings => {
context.storage_ready
&& !context.saving
&& !context.switching_vault
&& !context.modal_open
}
UiAction::CommandPalette => !context.modal_open, UiAction::CommandPalette => !context.modal_open,
// Entry creation is not valid until the dedicated workflow exists. // Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false, UiAction::NewEntry => false,
@@ -269,7 +275,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
UiAction::CopyEditedField => { UiAction::CopyEditedField => {
context.unlocked && context.editing && !context.switching_vault && context.focused_field context.unlocked && context.editing && !context.switching_vault && context.focused_field
} }
UiAction::TogglePaneFocus => true, UiAction::TogglePaneFocus => !context.modal_open,
UiAction::Refresh => { UiAction::Refresh => {
context.storage_ready && !context.tree_loading && !context.switching_vault context.storage_ready && !context.tree_loading && !context.switching_vault
} }
@@ -345,11 +351,13 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
UiAction::ToggleReveal => "Wait for vault validation", UiAction::ToggleReveal => "Wait for vault validation",
UiAction::Lock => "The password store is already locked", UiAction::Lock => "The password store is already locked",
UiAction::CommandPalette => "Finish the current confirmation first", UiAction::CommandPalette => "Finish the current confirmation first",
UiAction::About UiAction::Settings if !context.storage_ready => "Shared configuration is unavailable",
| UiAction::Settings UiAction::Settings if context.saving => "Wait for the active save",
| UiAction::TogglePaneFocus UiAction::Settings if context.switching_vault => "Wait for vault validation",
| UiAction::Minimize UiAction::About | UiAction::Settings | UiAction::TogglePaneFocus | UiAction::Help => {
| UiAction::Help => "Action is unavailable", "Close the current screen first"
}
UiAction::Minimize => "Action is unavailable",
}) })
} }
@@ -506,6 +514,7 @@ mod tests {
..ready ..ready
}; };
for action in [ for action in [
UiAction::Settings,
UiAction::Save, UiAction::Save,
UiAction::CloseWindow, UiAction::CloseWindow,
UiAction::Quit, UiAction::Quit,
@@ -544,6 +553,25 @@ mod tests {
), ),
Some("Finish the current confirmation first") Some("Finish the current confirmation first")
); );
let modal = ActionContext {
modal_open: true,
..ready
};
for action in [
UiAction::About,
UiAction::Settings,
UiAction::TogglePaneFocus,
UiAction::Help,
] {
assert!(!enabled(action, modal), "{action:?}");
}
assert!(!enabled(
UiAction::Settings,
ActionContext {
storage_ready: false,
..ready
}
));
} }
#[test] #[test]
@@ -567,5 +595,10 @@ mod tests {
Some(expected) Some(expected)
); );
} }
assert_eq!(
shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE),
Some(UiAction::Help)
);
assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1"));
} }
} }

View File

@@ -66,6 +66,16 @@ enum Message {
DismissUtility, DismissUtility,
WindowResolved(UiAction, Option<window::Id>), WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>), FolderPicked(Result<Option<PathBuf>, String>),
SettingsVaultChanged(String),
SettingsDefaultKeyChanged(String),
SettingsTimeoutChanged(String),
PickSettingsVault,
SettingsVaultPicked(Result<Option<PathBuf>, String>),
SaveSettings,
SettingsFinished {
generation: u64,
result: Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>,
},
VaultSwitched { VaultSwitched {
generation: u64, generation: u64,
result: Box<Result<DesktopStorage, String>>, result: Box<Result<DesktopStorage, String>>,
@@ -174,6 +184,7 @@ struct App {
operation_generation: u64, operation_generation: u64,
tree_generation: u64, tree_generation: u64,
vault_generation: u64, vault_generation: u64,
settings_generation: u64,
panes: pane_grid::State<PaneKind>, panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus, pane_focus: PaneFocus,
navigation: NavigationTree, navigation: NavigationTree,
@@ -214,13 +225,54 @@ enum ContentMode {
Editor, Editor,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum UtilityView { enum UtilityView {
About, About,
Settings, Settings(SettingsForm),
Help, Help,
} }
#[derive(Clone, Debug, Eq, PartialEq)]
struct SettingsForm {
vault: String,
default_key: String,
authentication_timeout: String,
saving: bool,
error: Option<String>,
}
impl SettingsForm {
fn new(storage: &DesktopStorage) -> Self {
Self {
vault: storage.vault().display().to_string(),
default_key: storage.default_key().to_owned(),
authentication_timeout: storage
.authentication_timeout()
.duration()
.as_secs()
.to_string(),
saving: false,
error: None,
}
}
fn settings(
&self,
storage: &DesktopStorage,
) -> Result<ironstorage::config::ConfigSettings, String> {
let timeout = self
.authentication_timeout
.trim()
.parse::<u64>()
.map_err(|_| "Authentication timeout must be a whole number of seconds.".to_owned())?;
let mut settings = storage.settings();
settings.set_vault(PathBuf::from(self.vault.trim()));
settings.set_default_key(self.default_key.trim().to_owned());
settings.set_authentication_timeout(Duration::from_secs(timeout));
Ok(settings)
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum TreeState { enum TreeState {
Loading, Loading,
@@ -276,6 +328,7 @@ impl App {
operation_generation: 0, operation_generation: 0,
tree_generation: 0, tree_generation: 0,
vault_generation: 0, vault_generation: 0,
settings_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical, axis: pane_grid::Axis::Vertical,
ratio: 0.28, ratio: 0.28,
@@ -345,13 +398,24 @@ impl App {
} }
Message::PaletteCancel => { Message::PaletteCancel => {
self.touch_user_activity(); self.touch_user_activity();
if self.utility.is_some() {
return self.update(Message::DismissUtility);
}
if self.palette.is_open() { if self.palette.is_open() {
self.palette.close(); self.palette.close();
return self.restore_focus(); return self.restore_focus();
} }
} }
Message::PaletteInvoke(action) => return self.invoke_palette_action(action), Message::PaletteInvoke(action) => return self.invoke_palette_action(action),
Message::DismissUtility => self.utility = None, Message::DismissUtility => {
if self.utility.as_ref().is_some_and(
|utility| matches!(utility, UtilityView::Settings(form) if form.saving),
) {
self.status = "Wait for settings validation to finish…".to_owned();
} else {
self.utility = None;
}
}
Message::WindowResolved(action, id) => { Message::WindowResolved(action, id) => {
let Some(id) = id else { let Some(id) = id else {
return Task::none(); return Task::none();
@@ -374,6 +438,103 @@ impl App {
format!("Folder picker failed: {error}. Current vault unchanged."); format!("Folder picker failed: {error}. Current vault unchanged.");
} }
}, },
Message::SettingsVaultChanged(vault) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility
&& !form.saving
{
form.vault = vault;
form.error = None;
}
}
Message::SettingsDefaultKeyChanged(default_key) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility
&& !form.saving
{
form.default_key = default_key;
form.error = None;
}
}
Message::SettingsTimeoutChanged(timeout) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility
&& !form.saving
{
form.authentication_timeout = timeout;
form.error = None;
}
}
Message::PickSettingsVault => {
let initial = match &self.utility {
Some(UtilityView::Settings(form)) if !form.saving => {
Some(PathBuf::from(&form.vault))
}
_ => None,
};
if initial.is_some() {
return Task::perform(
folder_picker::pick_folder(initial),
Message::SettingsVaultPicked,
);
}
}
Message::SettingsVaultPicked(result) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility
&& !form.saving
{
match result {
Ok(Some(path)) => {
form.vault = path.display().to_string();
form.error = None;
}
Ok(None) => {}
Err(error) => form.error = Some(format!("Folder picker failed: {error}")),
}
}
}
Message::SaveSettings => return self.begin_settings_save(),
Message::SettingsFinished { generation, result } => {
if generation != self.settings_generation {
if let Ok((_storage, session, _key)) = *result {
let _ignored = session.manual_lock();
}
return Task::none();
}
match *result {
Ok((storage, session, key)) => {
if let Some(current) = &self.session {
let _ignored = current.manual_lock();
}
self.authentication_generation =
self.authentication_generation.wrapping_add(1);
self.operation_generation = self.operation_generation.wrapping_add(1);
self.sensitive.clear();
self.storage = Some(storage);
self.session = Some(session);
self.key = Some(key);
self.handle = None;
self.authentication = AuthenticationView::Locked;
self.editor = None;
self.content_mode = ContentMode::Viewer;
self.navigation = NavigationTree::default();
self.tree_state = TreeState::Loading;
self.entry_path.clear();
self.confirmation = None;
self.after_save = None;
self.after_authentication = None;
self.generate_confirmation = None;
self.conflict = false;
self.utility = None;
self.status = "Settings saved; protected content was locked.".to_owned();
return self.begin_tree_refresh();
}
Err(error) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility {
form.saving = false;
form.error = Some(error.clone());
}
self.status = format!("Settings were not saved: {error}");
}
}
}
Message::VaultSwitched { generation, result } => { Message::VaultSwitched { generation, result } => {
if generation != self.vault_generation { if generation != self.vault_generation {
return Task::none(); return Task::none();
@@ -766,7 +927,9 @@ impl App {
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty), dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving, saving: self.saving,
switching_vault: self.switching_vault, switching_vault: self.switching_vault,
modal_open: self.confirmation.is_some() || self.generate_confirmation.is_some(), modal_open: self.confirmation.is_some()
|| self.generate_confirmation.is_some()
|| self.utility.is_some(),
focused_field: focused.is_some(), focused_field: focused.is_some(),
focused_sensitive: focused focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -797,7 +960,12 @@ impl App {
} }
match action { match action {
UiAction::About => self.utility = Some(UtilityView::About), UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings), UiAction::Settings => {
if let Some(storage) = &self.storage {
self.utility = Some(UtilityView::Settings(SettingsForm::new(storage)));
return iced::widget::operation::focus(settings_vault_id());
}
}
UiAction::Help => self.utility = Some(UtilityView::Help), UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::CommandPalette => return self.toggle_palette(), UiAction::CommandPalette => return self.toggle_palette(),
UiAction::OpenFolder => { UiAction::OpenFolder => {
@@ -966,6 +1134,43 @@ impl App {
} }
} }
fn begin_settings_save(&mut self) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
};
let settings = match &self.utility {
Some(UtilityView::Settings(form)) if !form.saving => match form.settings(&storage) {
Ok(settings) => settings,
Err(error) => {
if let Some(UtilityView::Settings(form)) = &mut self.utility {
form.error = Some(error.clone());
}
self.status = error;
return Task::none();
}
},
_ => return Task::none(),
};
self.settings_generation = self.settings_generation.wrapping_add(1);
let generation = self.settings_generation;
if let Some(UtilityView::Settings(form)) = &mut self.utility {
form.saving = true;
form.error = None;
}
self.status = "Validating and saving shared settings…".to_owned();
Task::perform(
async move {
Box::new(
storage
.update_settings_and_bootstrap(settings)
.map(|bootstrap| bootstrap.into_parts())
.map_err(|error| error.to_string()),
)
},
move |result| Message::SettingsFinished { generation, result },
)
}
fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> { fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> {
let Some(storage) = self.storage.clone() else { let Some(storage) = self.storage.clone() else {
self.status = "Load a valid shared configuration before opening a folder.".to_owned(); self.status = "Load a valid shared configuration before opening a folder.".to_owned();
@@ -1177,7 +1382,7 @@ impl App {
if let Some(id) = self.generate_confirmation { if let Some(id) = self.generate_confirmation {
return generate_confirmation_view(id); return generate_confirmation_view(id);
} }
if let Some(utility) = self.utility { if let Some(utility) = &self.utility {
return utility_view(self, utility); return utility_view(self, utility);
} }
@@ -1267,6 +1472,10 @@ fn content_focus_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-path") iced::widget::Id::new("desktop-entry-path")
} }
fn settings_vault_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-settings-vault")
}
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id { fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
format!("desktop-entry-field-{}", id.value()).into() format!("desktop-entry-field-{}", id.value()).into()
} }
@@ -1356,62 +1565,133 @@ fn platform_menu_bar(app: &App) -> Element<'_, Message> {
container(menu).width(Length::Fill).into() container(menu).width(Length::Fill).into()
} }
fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> { fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Message> {
let mut content = column![].spacing(10); let mut content = column![].spacing(10);
match utility { match utility {
UtilityView::About => { UtilityView::About => {
content = content content = content
.push(text(ironstorage::PRODUCT_NAME).size(28)) .push(text(ironstorage::PRODUCT_NAME).size(28))
.push(text(format!("Version {}", env!("CARGO_PKG_VERSION")))) .push(text(format!("Version {}", env!("CARGO_PKG_VERSION"))))
.push(text("A native, pass-compatible password-store client.")); .push(text("A native, pass-compatible password-store client."))
.push(text("Compatible with pass and pass-otp."))
.push(text("Copyright IronStorage contributors."))
.push(text(
"Project license: pending selection. Third-party licenses are documented in DEPENDENCIES.md.",
));
} }
UtilityView::Settings => { UtilityView::Settings(form) => {
content = content.push(text("Settings").size(28)); content = content
if let Some(storage) = &app.storage { .push(text("Settings").size(28))
let editor = storage.configured_editor().map_or_else( .push(text("Password-store folder"))
|| "Environment or built-in fallback".to_owned(), .push(
|editor| { row![
std::iter::once(editor.program()) text_input("Vault path", &form.vault)
.chain(editor.arguments().iter().map(String::as_str)) .id(settings_vault_id())
.collect::<Vec<_>>() .on_input(Message::SettingsVaultChanged)
.join(" ") .on_submit(Message::SaveSettings),
if form.saving {
button("Choose…")
} else {
button("Choose…").on_press(Message::PickSettingsVault)
}, },
]
.spacing(8),
)
.push(text("Default OpenPGP key fingerprint or identity"))
.push(
text_input("Default key", &form.default_key)
.on_input(Message::SettingsDefaultKeyChanged)
.on_submit(Message::SaveSettings),
)
.push(text(
"Authentication inactivity timeout in seconds (186400)",
))
.push(
text_input("Timeout seconds", &form.authentication_timeout)
.on_input(Message::SettingsTimeoutChanged)
.on_submit(Message::SaveSettings),
); );
if let Some(storage) = &app.storage {
content = content content = content
.push(text(format!( .push(text(format!(
"Configuration: {}", "Shared configuration: {}",
storage.config_source().display() storage.config_source().display()
))) )))
.push(text(format!("Vault: {}", storage.vault().display())))
.push(text(format!("Default key: {}", storage.default_key())))
.push(text(format!("Editor: {editor}")))
.push(text(format!( .push(text(format!(
"Authentication inactivity timeout: {} seconds", "Clipboard cleanup remains {} seconds.",
storage.authentication_timeout().duration().as_secs()
)))
.push(text(format!(
"Clipboard cleanup timeout: {} seconds",
storage.clipboard_timeout().duration().as_secs() storage.clipboard_timeout().duration().as_secs()
))) )))
.push(text("Values come from the shared validated configuration.")); .push(text(
} else { "Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.",
content = content.push(text("Shared configuration is still loading.")); ));
}
if let Some(error) = &form.error {
content = content.push(text(format!("Settings error: {error}")));
} }
} }
UtilityView::Help => { UtilityView::Help => {
content = content.push(text("Keyboard shortcuts").size(28)); content = content
.push(text("IronStorage Help").size(28))
.push(text("Keyboard shortcuts").size(22));
for spec in action::ACTIONS { for spec in action::ACTIONS {
if let Some(shortcut) = action::shortcut_label(spec.action) { if let Some(shortcut) = action::shortcut_label(spec.action) {
content = content.push(text(format!("{shortcut} {}", spec.label))); content = content.push(text(format!(
"{shortcut} {} · {}",
spec.label,
spec.group.label()
)));
} }
} }
content = content
.push(text("Two-pane navigation").size(22))
.push(text(
"The left pane navigates the storage-provided folder tree. The right pane opens the selected entry. Tab changes pane focus; arrows, Home, End, and Enter navigate the focused pane.",
))
.push(text("Sensitive values, copying, and locking").size(22))
.push(text(
"Opening protected content authenticates through the shared inactivity lease. Reveal and copy are explicit field actions. Copy uses the configured cleanup timeout. Lock immediately drops decrypted entry, editor, and clipboard state.",
))
.push(text("Command palette").size(22))
.push(text(
"Open the command palette with its platform shortcut, type a command or alias, use Up/Down to select, Enter to run, and Escape to cancel. Unavailable commands explain why they are disabled.",
))
.push(text("Git actions").size(22))
.push(text(
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.",
))
.push(text("OTP actions").size(22))
.push(text(
"OTP actions operate on storage-recognized fields. TOTP codes are time-based; HOTP generation confirms and commits the counter advance. URI, QR, and clipboard outputs are explicit sensitive presentations.",
));
} }
} }
container( let done = button("Done (Esc)");
column![ let done = if matches!(utility, UtilityView::Settings(form) if form.saving) {
scrollable(content).height(Length::Fill), done
button("Done").on_press(Message::DismissUtility), } else {
done.on_press(Message::DismissUtility)
};
let actions = match utility {
UtilityView::Settings(form) => {
let save = button(if form.saving {
"Validating…"
} else {
"Save Settings"
});
row![
if form.saving {
save
} else {
save.on_press(Message::SaveSettings)
},
done,
] ]
.spacing(8)
}
UtilityView::About | UtilityView::Help => row![done],
};
container(
column![scrollable(content).height(Length::Fill), actions,]
.spacing(12) .spacing(12)
.padding(20), .padding(20),
) )
@@ -2114,6 +2394,7 @@ mod tests {
operation_generation: 0, operation_generation: 0,
tree_generation: 0, tree_generation: 0,
vault_generation: 0, vault_generation: 0,
settings_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical, axis: pane_grid::Axis::Vertical,
ratio: 0.28, ratio: 0.28,
@@ -2430,10 +2711,12 @@ mod tests {
assert!(app.status.contains("Finish the current confirmation")); assert!(app.status.contains("Finish the current confirmation"));
app.confirmation = None; app.confirmation = None;
let (_temporary, storage) = fixture_storage();
app.storage = Some(storage);
let _task = app.update(Message::Action(UiAction::CommandPalette)); let _task = app.update(Message::Action(UiAction::CommandPalette));
let _task = app.update(Message::PaletteInvoke(UiAction::Settings)); let _task = app.update(Message::PaletteInvoke(UiAction::Settings));
assert!(!app.palette.is_open()); assert!(!app.palette.is_open());
assert_eq!(app.utility, Some(UtilityView::Settings)); assert!(matches!(app.utility, Some(UtilityView::Settings(_))));
let escape = Event::Keyboard(keyboard::Event::KeyPressed { let escape = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape), key: keyboard::Key::Named(keyboard::key::Named::Escape),
@@ -2450,6 +2733,40 @@ mod tests {
)); ));
} }
#[test]
fn settings_form_validates_and_storage_persists_atomically() {
let (_temporary, storage) = fixture_storage();
let before = fs::read(storage.config_source()).expect("original config");
let mut form = SettingsForm::new(&storage);
assert_eq!(form.vault, storage.vault().display().to_string());
assert_eq!(form.default_key, storage.default_key());
form.authentication_timeout = "not-a-number".to_owned();
assert!(form.settings(&storage).is_err());
form.authentication_timeout = "600".to_owned();
form.default_key = "missing-key".to_owned();
let invalid = form.settings(&storage).expect("syntactically valid form");
assert!(storage.update_settings(invalid).is_err());
assert_eq!(
fs::read(storage.config_source()).expect("rejected config"),
before
);
form.default_key = storage.default_key().to_owned();
let updated = storage
.update_settings(form.settings(&storage).expect("valid form"))
.expect("persisted settings");
assert_eq!(
updated.authentication_timeout().duration(),
Duration::from_secs(600)
);
let reloaded = DesktopStorage::load(Some(storage.config_source())).expect("reload config");
assert_eq!(
reloaded.authentication_timeout().duration(),
Duration::from_secs(600)
);
}
#[test] #[test]
fn vault_switch_result_replaces_state_only_after_storage_success() { fn vault_switch_result_replaces_state_only_after_storage_success() {
let (_temporary, storage) = fixture_storage(); let (_temporary, storage) = fixture_storage();

View File

@@ -20,7 +20,7 @@ impl NativeMenu {
let submenu = Submenu::new(group.label(), true); let submenu = Submenu::new(group.label(), true);
match group { match group {
MenuGroup::App => { MenuGroup::App => {
submenu.append(&PredefinedMenuItem::about(None, None))?; append_action(&submenu, UiAction::About, context, &mut items)?;
submenu.append(&PredefinedMenuItem::separator())?; submenu.append(&PredefinedMenuItem::separator())?;
append_action(&submenu, UiAction::Settings, context, &mut items)?; append_action(&submenu, UiAction::Settings, context, &mut items)?;
submenu.append(&PredefinedMenuItem::separator())?; submenu.append(&PredefinedMenuItem::separator())?;

View File

@@ -82,11 +82,14 @@ impl DesktopStorage {
} }
pub fn system() -> Result<DesktopBootstrap, DesktopError> { pub fn system() -> Result<DesktopBootstrap, DesktopError> {
let storage = Self::load(None)?; Self::load(None)?.bootstrap()
let keys = KeyStore::load(storage.config.key_material()) }
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
let keys = KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let handle = keys let handle = keys
.resolve(storage.config.default_key().as_str()) .resolve(self.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let key = keys let key = keys
.infos() .infos()
@@ -99,11 +102,11 @@ impl DesktopStorage {
})?; })?;
let authentication = NativeAuthenticationSession::system( let authentication = NativeAuthenticationSession::system(
SecretProtectionPolicy::default(), SecretProtectionPolicy::default(),
storage.config.authentication_timeout(), self.config.authentication_timeout(),
) )
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
Ok(DesktopBootstrap { Ok(DesktopBootstrap {
storage, storage: self,
authentication, authentication,
key, key,
}) })
@@ -137,7 +140,22 @@ impl DesktopStorage {
self.config.settings() self.config.settings()
} }
pub fn update_settings(&self, mut settings: ConfigSettings) -> Result<Self, DesktopError> { pub fn update_settings(&self, settings: ConfigSettings) -> Result<Self, DesktopError> {
let storage = self.validate_settings(settings)?;
storage.persist()?;
Ok(storage)
}
pub fn update_settings_and_bootstrap(
&self,
settings: ConfigSettings,
) -> Result<DesktopBootstrap, DesktopError> {
let bootstrap = self.validate_settings(settings)?.bootstrap()?;
bootstrap.storage.persist()?;
Ok(bootstrap)
}
fn validate_settings(&self, mut settings: ConfigSettings) -> Result<Self, DesktopError> {
let repository = Repository::open(settings.vault()) let repository = Repository::open(settings.vault())
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?; .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
settings.set_vault(repository.root_path().to_owned()); settings.set_vault(repository.root_path().to_owned());
@@ -150,13 +168,15 @@ impl DesktopStorage {
keys.resolve(storage.config.default_key().as_str()) keys.resolve(storage.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
storage.tree()?; storage.tree()?;
storage
.config
.persist()
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
Ok(storage) Ok(storage)
} }
fn persist(&self) -> Result<(), DesktopError> {
self.config
.persist()
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
}
/// Validate a selected folder through the same repository and key path as /// Validate a selected folder through the same repository and key path as
/// normal desktop reads, then atomically update the shared configuration. /// normal desktop reads, then atomically update the shared configuration.
pub fn switch_vault(&self, vault: &Path) -> Result<Self, DesktopError> { pub fn switch_vault(&self, vault: &Path) -> Result<Self, DesktopError> {