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

@@ -15,5 +15,8 @@ iced.workspace = true
ironstorage.workspace = true
zeroize.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
muda.workspace = true
[dev-dependencies]
tempfile = "3"

376
apps/desktop/src/action.rs Normal file
View File

@@ -0,0 +1,376 @@
//! One desktop action registry shared by every presentation entry point.
use iced::keyboard::{self, key::Named};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum UiAction {
About,
Settings,
NewEntry,
OpenEntry,
Save,
CloseWindow,
Quit,
Undo,
Redo,
Cut,
CopyField,
CopyEditedField,
Paste,
Find,
TogglePaneFocus,
Refresh,
ReloadEntry,
EditEntry,
ToggleReveal,
Lock,
Minimize,
Help,
}
impl UiAction {
pub const fn id(self) -> &'static str {
match self {
Self::About => "about",
Self::Settings => "settings",
Self::NewEntry => "new-entry",
Self::OpenEntry => "open-entry",
Self::Save => "save",
Self::CloseWindow => "close-window",
Self::Quit => "quit",
Self::Undo => "undo",
Self::Redo => "redo",
Self::Cut => "cut",
Self::CopyField => "copy-field",
Self::CopyEditedField => "copy-edited-field",
Self::Paste => "paste",
Self::Find => "find",
Self::TogglePaneFocus => "toggle-pane-focus",
Self::Refresh => "refresh",
Self::ReloadEntry => "reload-entry",
Self::EditEntry => "edit-entry",
Self::ToggleReveal => "toggle-reveal",
Self::Lock => "lock",
Self::Minimize => "minimize",
Self::Help => "help",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MenuGroup {
App,
File,
Edit,
View,
Entry,
Tools,
Window,
Help,
}
impl MenuGroup {
pub const ALL: [Self; 8] = [
Self::App,
Self::File,
Self::Edit,
Self::View,
Self::Entry,
Self::Tools,
Self::Window,
Self::Help,
];
pub const fn label(self) -> &'static str {
match self {
Self::App => "IronStorage",
Self::File => "File",
Self::Edit => "Edit",
Self::View => "View",
Self::Entry => "Entry",
Self::Tools => "Tools",
Self::Window => "Window",
Self::Help => "Help",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionContext {
pub storage_ready: bool,
pub tree_loading: bool,
pub unlocked: bool,
pub document_open: bool,
pub editing: bool,
pub dirty: bool,
pub saving: bool,
pub focused_field: bool,
pub focused_sensitive: bool,
pub entry_path: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionSpec {
pub action: UiAction,
pub group: MenuGroup,
pub label: &'static str,
pub shortcut: Option<&'static str>,
}
pub const ACTIONS: &[ActionSpec] = &[
spec(UiAction::About, MenuGroup::App, "About IronStorage", None),
spec(UiAction::Settings, MenuGroup::App, "Settings…", Some("⌘,")),
spec(
UiAction::Quit,
MenuGroup::App,
"Quit IronStorage",
Some("⌘Q"),
),
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")),
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
spec(
UiAction::CloseWindow,
MenuGroup::File,
"Close Window",
Some("⌘W"),
),
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")),
spec(UiAction::CopyField, MenuGroup::Entry, "Copy Field", None),
spec(
UiAction::CopyEditedField,
MenuGroup::Entry,
"Copy Edited Field",
None,
),
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
spec(
UiAction::TogglePaneFocus,
MenuGroup::View,
"Next Pane",
Some("Tab"),
),
spec(UiAction::Refresh, MenuGroup::View, "Refresh", Some("⌘R")),
spec(
UiAction::ReloadEntry,
MenuGroup::Entry,
"Reload Entry",
None,
),
spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None),
spec(
UiAction::ToggleReveal,
MenuGroup::Entry,
"Reveal or Hide Field",
None,
),
spec(UiAction::Lock, MenuGroup::Tools, "Lock", Some("⌘L")),
spec(
UiAction::Minimize,
MenuGroup::Window,
"Minimize",
Some("⌘M"),
),
spec(
UiAction::Help,
MenuGroup::Help,
"IronStorage Help",
Some("F1"),
),
];
const fn spec(
action: UiAction,
group: MenuGroup,
label: &'static str,
shortcut: Option<&'static str>,
) -> ActionSpec {
ActionSpec {
action,
group,
label,
shortcut,
}
}
pub fn spec_for(action: UiAction) -> &'static ActionSpec {
ACTIONS
.iter()
.find(|spec| spec.action == action)
.expect("every UI action is registered")
}
pub fn actions_in(group: MenuGroup) -> impl Iterator<Item = &'static ActionSpec> {
ACTIONS.iter().filter(move |spec| spec.group == group)
}
pub fn shortcut_label(action: UiAction) -> Option<String> {
let shortcut = spec_for(action).shortcut?;
if cfg!(target_os = "macos") {
Some(shortcut.to_owned())
} else {
Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+"))
}
}
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
match action {
UiAction::About | UiAction::Settings | UiAction::Help => true,
// Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false,
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving,
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving,
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true,
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
UiAction::CopyField => {
context.unlocked && context.document_open && !context.editing && context.focused_field
}
UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field,
UiAction::TogglePaneFocus => true,
UiAction::Refresh => context.storage_ready && !context.tree_loading,
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving,
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing,
UiAction::ToggleReveal => {
context.unlocked && context.document_open && context.focused_sensitive
}
UiAction::Lock => context.unlocked,
}
}
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
return Some(UiAction::TogglePaneFocus);
}
if key.as_ref() == keyboard::Key::Named(Named::F1) {
return Some(UiAction::Help);
}
if !modifiers.command() {
return None;
}
match key.as_ref() {
keyboard::Key::Character(",") => Some(UiAction::Settings),
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry),
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
keyboard::Key::Character("z" | "Z") if modifiers.shift() => Some(UiAction::Redo),
keyboard::Key::Character("z" | "Z") => Some(UiAction::Undo),
keyboard::Key::Character("x" | "X") => Some(UiAction::Cut),
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
fn context() -> ActionContext {
ActionContext {
storage_ready: true,
tree_loading: false,
unlocked: true,
document_open: true,
editing: true,
dirty: true,
saving: false,
focused_field: true,
focused_sensitive: true,
entry_path: true,
}
}
#[test]
fn registry_is_complete_unique_and_grouped_for_every_platform_menu() {
let actions = ACTIONS
.iter()
.map(|spec| spec.action)
.collect::<BTreeSet<_>>();
assert_eq!(actions.len(), ACTIONS.len());
for group in MenuGroup::ALL {
assert!(actions_in(group).next().is_some(), "missing {group:?} menu");
}
for action in actions {
assert_eq!(spec_for(action).action, action);
}
}
#[test]
fn enablement_closes_authentication_dirty_and_loading_bypasses() {
let ready = context();
assert!(!enabled(UiAction::NewEntry, ready));
assert!(enabled(UiAction::Save, ready));
assert!(enabled(UiAction::CopyEditedField, ready));
assert!(!enabled(UiAction::CopyField, ready));
assert!(enabled(UiAction::ToggleReveal, ready));
let viewing = ActionContext {
editing: false,
dirty: false,
..ready
};
assert!(enabled(UiAction::CopyField, viewing));
assert!(!enabled(UiAction::CopyEditedField, viewing));
let locked = ActionContext {
unlocked: false,
..ready
};
for action in [
UiAction::Save,
UiAction::CopyField,
UiAction::CopyEditedField,
UiAction::ToggleReveal,
UiAction::Lock,
] {
assert!(!enabled(action, locked));
}
assert!(!enabled(
UiAction::Refresh,
ActionContext {
tree_loading: true,
..ready
}
));
assert!(!enabled(
UiAction::Save,
ActionContext {
dirty: false,
..ready
}
));
}
#[test]
fn conventional_shortcuts_resolve_to_the_registered_actions() {
let primary = keyboard::Modifiers::COMMAND;
for (key, expected) in [
("o", UiAction::OpenEntry),
("s", UiAction::Save),
("f", UiAction::Find),
("n", UiAction::NewEntry),
("w", UiAction::CloseWindow),
("q", UiAction::Quit),
("x", UiAction::Cut),
("c", UiAction::CopyField),
("v", UiAction::Paste),
(",", UiAction::Settings),
] {
assert_eq!(
shortcut_action(&keyboard::Key::Character(key.into()), primary),
Some(expected)
);
}
}
}

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

View File

@@ -0,0 +1,141 @@
//! Safe macOS menu adapter over the shared desktop action registry.
use muda::{
Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use crate::action::{self, ActionContext, MenuGroup, UiAction};
pub struct NativeMenu {
_menu: Menu,
items: Vec<(UiAction, MenuItem)>,
}
impl NativeMenu {
pub fn install(context: ActionContext) -> Result<Self, muda::Error> {
let menu = Menu::new();
let mut items = Vec::new();
for group in MenuGroup::ALL {
let submenu = Submenu::new(group.label(), true);
match group {
MenuGroup::App => {
submenu.append(&PredefinedMenuItem::about(None, None))?;
submenu.append(&PredefinedMenuItem::separator())?;
append_action(&submenu, UiAction::Settings, context, &mut items)?;
submenu.append(&PredefinedMenuItem::separator())?;
submenu.append(&PredefinedMenuItem::services(None))?;
submenu.append(&PredefinedMenuItem::separator())?;
submenu.append(&PredefinedMenuItem::hide(None))?;
submenu.append(&PredefinedMenuItem::hide_others(None))?;
submenu.append(&PredefinedMenuItem::show_all(None))?;
submenu.append(&PredefinedMenuItem::separator())?;
append_action(&submenu, UiAction::Quit, context, &mut items)?;
}
MenuGroup::Window => {
submenu.append(&PredefinedMenuItem::minimize(None))?;
submenu.append(&PredefinedMenuItem::bring_all_to_front(None))?;
}
MenuGroup::Edit => {
submenu.append(&PredefinedMenuItem::undo(None))?;
submenu.append(&PredefinedMenuItem::redo(None))?;
submenu.append(&PredefinedMenuItem::separator())?;
submenu.append(&PredefinedMenuItem::cut(None))?;
submenu.append(&PredefinedMenuItem::copy(None))?;
submenu.append(&PredefinedMenuItem::paste(None))?;
submenu.append(&PredefinedMenuItem::select_all(None))?;
submenu.append(&PredefinedMenuItem::separator())?;
append_action(&submenu, UiAction::Find, context, &mut items)?;
}
_ => append_actions(&submenu, group, context, &mut items)?,
}
menu.append(&submenu)?;
}
menu.init_for_nsapp();
Ok(Self { _menu: menu, items })
}
pub fn sync(&self, context: ActionContext) {
for (action, item) in &self.items {
item.set_enabled(action::enabled(*action, context));
}
}
pub fn poll(&self) -> Option<UiAction> {
let event = MenuEvent::receiver().try_recv().ok()?;
self.items
.iter()
.find_map(|(action, item)| (item.id() == &event.id).then_some(*action))
}
}
fn append_actions(
submenu: &Submenu,
group: MenuGroup,
context: ActionContext,
items: &mut Vec<(UiAction, MenuItem)>,
) -> Result<(), muda::Error> {
for spec in action::actions_in(group).filter(|spec| {
!matches!(
spec.action,
UiAction::About
| UiAction::Undo
| UiAction::Redo
| UiAction::Cut
| UiAction::CopyEditedField
| UiAction::Paste
| UiAction::Minimize
| UiAction::TogglePaneFocus
)
}) {
append_action(submenu, spec.action, context, items)?;
}
Ok(())
}
fn append_action(
submenu: &Submenu,
action: UiAction,
context: ActionContext,
items: &mut Vec<(UiAction, MenuItem)>,
) -> Result<(), muda::Error> {
let spec = action::spec_for(action);
let item = MenuItem::with_id(
format!("ironstorage.{}", action.id()),
spec.label,
action::enabled(action, context),
accelerator(action),
);
submenu.append(&item)?;
items.push((action, item));
Ok(())
}
fn accelerator(action: UiAction) -> Option<Accelerator> {
let command = Modifiers::SUPER;
let (modifiers, code) = match action {
UiAction::Settings => (command, Code::Comma),
UiAction::NewEntry => (command, Code::KeyN),
UiAction::OpenEntry => (command, Code::KeyO),
UiAction::Save => (command, Code::KeyS),
UiAction::CloseWindow => (command, Code::KeyW),
UiAction::Quit => (command, Code::KeyQ),
UiAction::Undo => (command, Code::KeyZ),
UiAction::Redo => (command | Modifiers::SHIFT, Code::KeyZ),
UiAction::Cut => (command, Code::KeyX),
UiAction::Paste => (command, Code::KeyV),
UiAction::Find => (command, Code::KeyF),
UiAction::Refresh => (command, Code::KeyR),
UiAction::Lock => (command, Code::KeyL),
UiAction::Help => (Modifiers::empty(), Code::F1),
UiAction::About
| UiAction::CopyField
| UiAction::CopyEditedField
| UiAction::TogglePaneFocus
| UiAction::ReloadEntry
| UiAction::EditEntry
| UiAction::ToggleReveal
| UiAction::Minimize => return None,
};
Some(Accelerator::new(Some(modifiers), code))
}