Implement desktop help settings and about
This commit is contained in:
@@ -236,7 +236,13 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
|
||||
|
||||
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||
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,
|
||||
// Entry creation is not valid until the dedicated workflow exists.
|
||||
UiAction::NewEntry => false,
|
||||
@@ -269,7 +275,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||
UiAction::CopyEditedField => {
|
||||
context.unlocked && context.editing && !context.switching_vault && context.focused_field
|
||||
}
|
||||
UiAction::TogglePaneFocus => true,
|
||||
UiAction::TogglePaneFocus => !context.modal_open,
|
||||
UiAction::Refresh => {
|
||||
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::Lock => "The password store is already locked",
|
||||
UiAction::CommandPalette => "Finish the current confirmation first",
|
||||
UiAction::About
|
||||
| UiAction::Settings
|
||||
| UiAction::TogglePaneFocus
|
||||
| UiAction::Minimize
|
||||
| UiAction::Help => "Action is unavailable",
|
||||
UiAction::Settings if !context.storage_ready => "Shared configuration is unavailable",
|
||||
UiAction::Settings if context.saving => "Wait for the active save",
|
||||
UiAction::Settings if context.switching_vault => "Wait for vault validation",
|
||||
UiAction::About | UiAction::Settings | UiAction::TogglePaneFocus | UiAction::Help => {
|
||||
"Close the current screen first"
|
||||
}
|
||||
UiAction::Minimize => "Action is unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -506,6 +514,7 @@ mod tests {
|
||||
..ready
|
||||
};
|
||||
for action in [
|
||||
UiAction::Settings,
|
||||
UiAction::Save,
|
||||
UiAction::CloseWindow,
|
||||
UiAction::Quit,
|
||||
@@ -544,6 +553,25 @@ mod tests {
|
||||
),
|
||||
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]
|
||||
@@ -567,5 +595,10 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ enum Message {
|
||||
DismissUtility,
|
||||
WindowResolved(UiAction, Option<window::Id>),
|
||||
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 {
|
||||
generation: u64,
|
||||
result: Box<Result<DesktopStorage, String>>,
|
||||
@@ -174,6 +184,7 @@ struct App {
|
||||
operation_generation: u64,
|
||||
tree_generation: u64,
|
||||
vault_generation: u64,
|
||||
settings_generation: u64,
|
||||
panes: pane_grid::State<PaneKind>,
|
||||
pane_focus: PaneFocus,
|
||||
navigation: NavigationTree,
|
||||
@@ -214,13 +225,54 @@ enum ContentMode {
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum UtilityView {
|
||||
About,
|
||||
Settings,
|
||||
Settings(SettingsForm),
|
||||
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)]
|
||||
enum TreeState {
|
||||
Loading,
|
||||
@@ -276,6 +328,7 @@ impl App {
|
||||
operation_generation: 0,
|
||||
tree_generation: 0,
|
||||
vault_generation: 0,
|
||||
settings_generation: 0,
|
||||
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
||||
axis: pane_grid::Axis::Vertical,
|
||||
ratio: 0.28,
|
||||
@@ -345,13 +398,24 @@ impl App {
|
||||
}
|
||||
Message::PaletteCancel => {
|
||||
self.touch_user_activity();
|
||||
if self.utility.is_some() {
|
||||
return self.update(Message::DismissUtility);
|
||||
}
|
||||
if self.palette.is_open() {
|
||||
self.palette.close();
|
||||
return self.restore_focus();
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
let Some(id) = id else {
|
||||
return Task::none();
|
||||
@@ -374,6 +438,103 @@ impl App {
|
||||
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 } => {
|
||||
if generation != self.vault_generation {
|
||||
return Task::none();
|
||||
@@ -766,7 +927,9 @@ impl App {
|
||||
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
|
||||
saving: self.saving,
|
||||
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_sensitive: focused
|
||||
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
|
||||
@@ -797,7 +960,12 @@ impl App {
|
||||
}
|
||||
match action {
|
||||
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::CommandPalette => return self.toggle_palette(),
|
||||
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> {
|
||||
let Some(storage) = self.storage.clone() else {
|
||||
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 {
|
||||
return generate_confirmation_view(id);
|
||||
}
|
||||
if let Some(utility) = self.utility {
|
||||
if let Some(utility) = &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")
|
||||
}
|
||||
|
||||
fn settings_vault_id() -> iced::widget::Id {
|
||||
iced::widget::Id::new("desktop-settings-vault")
|
||||
}
|
||||
|
||||
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
|
||||
format!("desktop-entry-field-{}", id.value()).into()
|
||||
}
|
||||
@@ -1356,64 +1565,135 @@ fn platform_menu_bar(app: &App) -> Element<'_, Message> {
|
||||
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);
|
||||
match utility {
|
||||
UtilityView::About => {
|
||||
content = content
|
||||
.push(text(ironstorage::PRODUCT_NAME).size(28))
|
||||
.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 => {
|
||||
content = content.push(text("Settings").size(28));
|
||||
if let Some(storage) = &app.storage {
|
||||
let editor = storage.configured_editor().map_or_else(
|
||||
|| "Environment or built-in fallback".to_owned(),
|
||||
|editor| {
|
||||
std::iter::once(editor.program())
|
||||
.chain(editor.arguments().iter().map(String::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
},
|
||||
UtilityView::Settings(form) => {
|
||||
content = content
|
||||
.push(text("Settings").size(28))
|
||||
.push(text("Password-store folder"))
|
||||
.push(
|
||||
row![
|
||||
text_input("Vault path", &form.vault)
|
||||
.id(settings_vault_id())
|
||||
.on_input(Message::SettingsVaultChanged)
|
||||
.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 (1–86400)",
|
||||
))
|
||||
.push(
|
||||
text_input("Timeout seconds", &form.authentication_timeout)
|
||||
.on_input(Message::SettingsTimeoutChanged)
|
||||
.on_submit(Message::SaveSettings),
|
||||
);
|
||||
if let Some(storage) = &app.storage {
|
||||
content = content
|
||||
.push(text(format!(
|
||||
"Configuration: {}",
|
||||
"Shared configuration: {}",
|
||||
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!(
|
||||
"Authentication inactivity timeout: {} seconds",
|
||||
storage.authentication_timeout().duration().as_secs()
|
||||
)))
|
||||
.push(text(format!(
|
||||
"Clipboard cleanup timeout: {} seconds",
|
||||
"Clipboard cleanup remains {} seconds.",
|
||||
storage.clipboard_timeout().duration().as_secs()
|
||||
)))
|
||||
.push(text("Values come from the shared validated configuration."));
|
||||
} else {
|
||||
content = content.push(text("Shared configuration is still loading."));
|
||||
.push(text(
|
||||
"Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.",
|
||||
));
|
||||
}
|
||||
if let Some(error) = &form.error {
|
||||
content = content.push(text(format!("Settings error: {error}")));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let done = button("Done (Esc)");
|
||||
let done = if matches!(utility, UtilityView::Settings(form) if form.saving) {
|
||||
done
|
||||
} 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),
|
||||
button("Done").on_press(Message::DismissUtility),
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(20),
|
||||
column![scrollable(content).height(Length::Fill), actions,]
|
||||
.spacing(12)
|
||||
.padding(20),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -2114,6 +2394,7 @@ mod tests {
|
||||
operation_generation: 0,
|
||||
tree_generation: 0,
|
||||
vault_generation: 0,
|
||||
settings_generation: 0,
|
||||
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
||||
axis: pane_grid::Axis::Vertical,
|
||||
ratio: 0.28,
|
||||
@@ -2430,10 +2711,12 @@ mod tests {
|
||||
assert!(app.status.contains("Finish the current confirmation"));
|
||||
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::PaletteInvoke(UiAction::Settings));
|
||||
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 {
|
||||
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]
|
||||
fn vault_switch_result_replaces_state_only_after_storage_success() {
|
||||
let (_temporary, storage) = fixture_storage();
|
||||
|
||||
@@ -20,7 +20,7 @@ impl NativeMenu {
|
||||
let submenu = Submenu::new(group.label(), true);
|
||||
match group {
|
||||
MenuGroup::App => {
|
||||
submenu.append(&PredefinedMenuItem::about(None, None))?;
|
||||
append_action(&submenu, UiAction::About, context, &mut items)?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
append_action(&submenu, UiAction::Settings, context, &mut items)?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
|
||||
@@ -82,11 +82,14 @@ impl DesktopStorage {
|
||||
}
|
||||
|
||||
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
|
||||
let storage = Self::load(None)?;
|
||||
let keys = KeyStore::load(storage.config.key_material())
|
||||
Self::load(None)?.bootstrap()
|
||||
}
|
||||
|
||||
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
|
||||
let keys = KeyStore::load(self.config.key_material())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
||||
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))?;
|
||||
let key = keys
|
||||
.infos()
|
||||
@@ -99,11 +102,11 @@ impl DesktopStorage {
|
||||
})?;
|
||||
let authentication = NativeAuthenticationSession::system(
|
||||
SecretProtectionPolicy::default(),
|
||||
storage.config.authentication_timeout(),
|
||||
self.config.authentication_timeout(),
|
||||
)
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
|
||||
Ok(DesktopBootstrap {
|
||||
storage,
|
||||
storage: self,
|
||||
authentication,
|
||||
key,
|
||||
})
|
||||
@@ -137,7 +140,22 @@ impl DesktopStorage {
|
||||
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())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
|
||||
settings.set_vault(repository.root_path().to_owned());
|
||||
@@ -150,13 +168,15 @@ impl DesktopStorage {
|
||||
keys.resolve(storage.config.default_key().as_str())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
||||
storage.tree()?;
|
||||
storage
|
||||
.config
|
||||
.persist()
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
|
||||
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
|
||||
/// normal desktop reads, then atomically update the shared configuration.
|
||||
pub fn switch_vault(&self, vault: &Path) -> Result<Self, DesktopError> {
|
||||
|
||||
Reference in New Issue
Block a user