Implement desktop menus and shortcuts

This commit is contained in:
2026-08-10 16:52:51 +02:00
parent f6e74cbb1d
commit c301d9a3df
8 changed files with 1376 additions and 67 deletions

View File

@@ -1,7 +1,10 @@
#![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)]
mod action;
mod editor;
#[cfg(target_os = "macos")]
mod native_menu;
mod navigation;
use std::{
@@ -40,18 +43,28 @@ use ironstorage::{
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
use zeroize::Zeroizing;
use action::{ActionContext, MenuGroup, UiAction};
#[cfg(target_os = "macos")]
use native_menu::NativeMenu;
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, DesktopError>)>>>;
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
#[derive(Clone)]
enum Message {
Action(UiAction),
FieldAction(EntryFieldId, UiAction),
ToggleMenu(MenuGroup),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
TreeLoaded {
generation: u64,
completion: TreeCompletion,
},
RefreshTree,
SidebarActivate(TreeNodeId),
SidebarNavigate(NavigationKey),
TogglePaneFocus,
@@ -83,7 +96,6 @@ enum Message {
generation: u64,
result: Result<String, String>,
},
Save,
SaveFinished {
generation: u64,
completion: SaveCompletion,
@@ -163,6 +175,10 @@ struct App {
generate_confirmation: Option<EntryFieldId>,
conflict: bool,
status: String,
open_menu: Option<MenuGroup>,
utility: Option<UtilityView>,
#[cfg(target_os = "macos")]
native_menu: Option<NativeMenu>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -183,6 +199,13 @@ enum ContentMode {
Editor,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum UtilityView {
About,
Settings,
Help,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum TreeState {
Loading,
@@ -226,37 +249,47 @@ fn main() -> iced::Result {
impl App {
fn new() -> (Self, Task<Message>) {
let mut app = Self {
authentication: AuthenticationView::Loading,
storage: None,
session: None,
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor: None,
content_mode: ContentMode::Viewer,
saving: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
conflict: false,
status: "Loading shared configuration…".to_owned(),
open_menu: None,
utility: None,
#[cfg(target_os = "macos")]
native_menu: None,
};
#[cfg(target_os = "macos")]
match NativeMenu::install(app.action_context()) {
Ok(menu) => app.native_menu = Some(menu),
Err(error) => app.status = format!("Native menu unavailable: {error}"),
}
(
Self {
authentication: AuthenticationView::Loading,
storage: None,
session: None,
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor: None,
content_mode: ContentMode::Viewer,
saving: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
conflict: false,
status: "Loading shared configuration…".to_owned(),
},
app,
Task::perform(load_authentication(), |result| {
Message::StartupLoaded(Box::new(result))
}),
@@ -265,6 +298,35 @@ impl App {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Action(action) => return self.invoke_action(action),
Message::FieldAction(id, action) => {
if let Some(editor) = self.editor.as_mut() {
editor.select(id);
}
return self.invoke_action(action);
}
Message::ToggleMenu(group) => {
self.open_menu = (self.open_menu != Some(group)).then_some(group);
}
Message::DismissUtility => self.utility = None,
Message::WindowResolved(action, id) => {
let Some(id) = id else {
return Task::none();
};
return match action {
UiAction::CloseWindow | UiAction::Quit => {
self.request_action(PendingAction::CloseWindow(id))
}
UiAction::Minimize => window::minimize(id, true),
_ => Task::none(),
};
}
#[cfg(target_os = "macos")]
Message::PollNativeMenu => {
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
return self.invoke_action(action);
}
}
Message::StartupLoaded(result) => match *result {
Ok((storage, session, key)) => {
self.storage = Some(storage);
@@ -307,7 +369,6 @@ impl App {
}
}
}
Message::RefreshTree => return self.begin_tree_refresh(),
Message::SidebarActivate(id) => {
self.touch_user_activity();
self.pane_focus = PaneFocus::Sidebar;
@@ -484,7 +545,6 @@ impl App {
self.status = result.unwrap_or_else(|error| format!("Clipboard: {error}"));
}
}
Message::Save => return self.begin_save(),
Message::SaveFinished {
generation,
completion,
@@ -575,6 +635,81 @@ impl App {
Task::none()
}
fn action_context(&self) -> ActionContext {
let focused = self
.editor
.as_ref()
.and_then(|editor| editor.focused().and_then(|id| editor.document().field(id)));
ActionContext {
storage_ready: self.storage.is_some(),
tree_loading: self.tree_state == TreeState::Loading,
unlocked: authentication_allows_content(&self.authentication),
document_open: self.editor.is_some(),
editing: self.content_mode == ContentMode::Editor,
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving,
focused_field: focused.is_some(),
focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
entry_path: !self.entry_path.trim().is_empty(),
}
}
fn invoke_action(&mut self, action: UiAction) -> Task<Message> {
self.open_menu = None;
if !action::enabled(action, self.action_context()) {
if self.content_mode == ContentMode::Editor
&& matches!(
action,
UiAction::Undo
| UiAction::Redo
| UiAction::Cut
| UiAction::CopyField
| UiAction::Paste
)
{
return Task::none();
}
self.status = format!(
"{} is unavailable in the current state.",
action::spec_for(action).label
);
return Task::none();
}
match action {
UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::OpenEntry => return self.update(Message::OpenEntry),
UiAction::Save => return self.begin_save(),
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => {
return window::oldest().map(move |id| Message::WindowResolved(action, id));
}
UiAction::CopyField | UiAction::CopyEditedField => {
if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) {
return self.update(Message::Copy(id));
}
}
UiAction::TogglePaneFocus => return self.update(Message::TogglePaneFocus),
UiAction::Refresh => return self.begin_tree_refresh(),
UiAction::ReloadEntry => return self.update(Message::RequestReload),
UiAction::EditEntry => return self.update(Message::BeginEdit),
UiAction::ToggleReveal => {
if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) {
return self.update(Message::ToggleReveal(id));
}
}
UiAction::Lock => return self.update(Message::Lock),
UiAction::NewEntry
| UiAction::Undo
| UiAction::Redo
| UiAction::Cut
| UiAction::Paste
| UiAction::Find => {}
}
Task::none()
}
fn begin_tree_refresh(&mut self) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
@@ -810,20 +945,30 @@ impl App {
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
let mut subscriptions = vec![
time::every(Duration::from_secs(1)).map(|_| Message::Tick),
event::listen_with(|event, _status, _window| event_message(&event)),
window::close_requests().map(Message::RequestClose),
])
];
#[cfg(target_os = "macos")]
subscriptions.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu));
Subscription::batch(subscriptions)
}
fn view(&self) -> Element<'_, Message> {
#[cfg(target_os = "macos")]
if let Some(menu) = &self.native_menu {
menu.sync(self.action_context());
}
if let Some(action) = &self.confirmation {
return confirmation_view(action);
}
if let Some(id) = self.generate_confirmation {
return generate_confirmation_view(id);
}
if let Some(utility) = self.utility {
return utility_view(self, utility);
}
let authentication = match &self.authentication {
AuthenticationView::Loading => "Loading".to_owned(),
@@ -857,6 +1002,7 @@ impl App {
]
.spacing(16)
.padding(10),
platform_menu_bar(self),
panes,
]
.height(Length::Fill),
@@ -875,6 +1021,95 @@ fn viewer_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-viewer")
}
fn platform_menu_bar(app: &App) -> Element<'_, Message> {
if cfg!(target_os = "macos") {
return container(row![]).height(Length::Fixed(0.0)).into();
}
let mut headers = row![].spacing(2).padding([0, 8]);
for group in MenuGroup::ALL {
headers = headers.push(
button(group.label())
.on_press(Message::ToggleMenu(group))
.style(if app.open_menu == Some(group) {
button::primary
} else {
button::text
}),
);
}
let mut menu = column![headers].spacing(4);
if let Some(group) = app.open_menu {
let context = app.action_context();
let mut actions = row![].spacing(4).padding([6, 8]);
for spec in action::actions_in(group) {
let label = action::shortcut_label(spec.action).map_or_else(
|| spec.label.to_owned(),
|key| format!("{} {key}", spec.label),
);
let item = button(text(label));
actions = actions.push(if action::enabled(spec.action, context) {
item.on_press(Message::Action(spec.action))
} else {
item
});
}
menu = menu.push(
scrollable(actions).direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(),
)),
);
}
container(menu).width(Length::Fill).into()
}
fn utility_view(app: &App, utility: UtilityView) -> Element<'_, 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."));
}
UtilityView::Settings => {
content = content.push(text("Settings").size(28));
if let Some(storage) = &app.storage {
content = content
.push(text(format!(
"Authentication inactivity timeout: {} seconds",
storage.authentication_timeout().duration().as_secs()
)))
.push(text(format!(
"Clipboard cleanup timeout: {} 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."));
}
}
UtilityView::Help => {
content = content.push(text("Keyboard shortcuts").size(28));
for spec in action::ACTIONS {
if let Some(shortcut) = action::shortcut_label(spec.action) {
content = content.push(text(format!("{shortcut} {}", spec.label)));
}
}
}
}
container(
column![
scrollable(content).height(Length::Fill),
button("Done").on_press(Message::DismissUtility),
]
.spacing(12)
.padding(20),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn sidebar_view<'a>(
navigation: &'a NavigationTree,
state: &'a TreeState,
@@ -888,7 +1123,7 @@ fn sidebar_view<'a>(
"Password Store"
})
.size(20),
button("Refresh").on_press(Message::RefreshTree),
button("Refresh").on_press(Message::Action(UiAction::Refresh)),
]
.spacing(8)
]
@@ -965,10 +1200,10 @@ fn content_view(app: &App) -> Element<'_, Message> {
.size(20),
text_input("Entry path", &app.entry_path)
.on_input(Message::EntryPathChanged)
.on_submit(Message::OpenEntry),
button("Open").on_press(Message::OpenEntry),
button("Reload").on_press(Message::RequestReload),
button("Lock").on_press(Message::Lock),
.on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
button("Reload").on_press(Message::Action(UiAction::ReloadEntry)),
button("Lock").on_press(Message::Action(UiAction::Lock)),
]
.spacing(8);
@@ -1054,7 +1289,7 @@ fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> {
let mut rows = column![
row![
text(editor.entry()).size(22),
button("Edit entry").on_press(Message::BeginEdit),
button("Edit entry").on_press(Message::Action(UiAction::EditEntry)),
]
.spacing(8),
text("Use Up/Down/Home/End to select fields; Enter reveals or hides a selected sensitive value.")
@@ -1073,15 +1308,18 @@ fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> {
ViewerValue::Text(value) => value,
ViewerValue::Unavailable => "(binary value)",
};
let mut actions =
row![button(text(format!("Copy {label}"))).on_press(Message::Copy(id)),].spacing(6);
let mut actions = row![
button(text(format!("Copy {label}")))
.on_press(Message::FieldAction(id, UiAction::CopyField)),
]
.spacing(6);
if field.metadata().sensitivity() == EntrySensitivity::Sensitive {
actions = actions.push(
button(text(format!(
"{} {label}",
if revealed { "Hide" } else { "Reveal" }
)))
.on_press(Message::ToggleReveal(id)),
.on_press(Message::FieldAction(id, UiAction::ToggleReveal)),
);
}
let mut field_view = column![
@@ -1150,7 +1388,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
if editor.is_dirty() { "" } else { "" }
))
.size(22),
button("Save").on_press(Message::Save),
button("Save").on_press(Message::Action(UiAction::Save)),
button("Add line").on_press(Message::AddAfter(None)),
]
.spacing(8)
@@ -1197,7 +1435,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
button("").on_press(Message::MoveDown(id)),
button("Add below").on_press(Message::AddAfter(Some(id))),
button("Remove").on_press(Message::Remove(id)),
button("Copy value").on_press(Message::Copy(id)),
button("Copy value").on_press(Message::FieldAction(id, UiAction::CopyEditedField)),
]
.spacing(6);
if sensitive {
@@ -1207,7 +1445,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
} else {
"Reveal"
})
.on_press(Message::ToggleReveal(id)),
.on_press(Message::FieldAction(id, UiAction::ToggleReveal)),
);
}
if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri {
@@ -1331,13 +1569,10 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
fn event_message(event: &Event) -> Option<Message> {
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) {
return Some(Message::Save);
if let Some(action) = action::shortcut_action(key, *modifiers) {
return Some(Message::Action(action));
}
let navigation = match key.as_ref() {
keyboard::Key::Named(keyboard::key::Named::Tab) => {
return Some(Message::TogglePaneFocus);
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous,
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next,
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse,
@@ -1574,6 +1809,10 @@ mod tests {
generate_confirmation: None,
conflict: false,
status: String::new(),
open_menu: None,
utility: None,
#[cfg(target_os = "macos")]
native_menu: None,
}
}
@@ -1787,6 +2026,26 @@ mod tests {
);
}
#[test]
fn shared_actions_cannot_bypass_authentication_or_dirty_confirmation() {
let (_temporary, storage) = fixture_storage();
let mut editor = empty_editor(&storage, "draft");
editor.add_after(None).expect("dirty line");
let mut app = test_app(Some(editor));
app.content_mode = ContentMode::Editor;
assert!(!action::enabled(UiAction::Save, app.action_context()));
let _task = app.invoke_action(UiAction::Save);
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
app.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
assert!(action::enabled(UiAction::Save, app.action_context()));
let id = window::Id::unique();
let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id)));
assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
}
#[test]
fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() {
let (_temporary, storage) = fixture_storage();
@@ -1840,7 +2099,10 @@ mod tests {
text: Some("s".into()),
repeat: false,
});
assert!(matches!(event_message(&save), Some(Message::Save)));
assert!(matches!(
event_message(&save),
Some(Message::Action(UiAction::Save))
));
let tab = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Tab),
@@ -1853,7 +2115,7 @@ mod tests {
});
assert!(matches!(
event_message(&tab),
Some(Message::TogglePaneFocus)
Some(Message::Action(UiAction::TogglePaneFocus))
));
}