diff --git a/README.md b/README.md index 573950d..5f6cb28 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ differences, and executable security audit are documented in [`docs/cli-parity.md`](docs/cli-parity.md). The Mutt-inspired terminal interaction model, default keys, and complete TUI operation matrix are documented in [`apps/tui/COMMANDS.md`](apps/tui/COMMANDS.md). +Desktop parity, native platform smoke checks, accessibility limitations, and +the executable security audit are documented in +[`docs/desktop-audit.md`](docs/desktop-audit.md). Lossless structured entry fields, semantic/sensitivity metadata, conflict tokens, and atomic frontend saves are documented in [`docs/entry-documents.md`](docs/entry-documents.md). diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index 1a351d0..3659f39 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -2,6 +2,32 @@ use iced::keyboard::{self, key::Named}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DesktopPlatform { + MacOs, + Linux, + Windows, +} + +impl DesktopPlatform { + pub const fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(target_os = "windows") { + Self::Windows + } else { + Self::Linux + } + } + + const fn primary_modifier(self) -> keyboard::Modifiers { + match self { + Self::MacOs => keyboard::Modifiers::LOGO, + Self::Linux | Self::Windows => keyboard::Modifiers::CTRL, + } + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum UiAction { About, @@ -314,8 +340,12 @@ pub fn actions_in(group: MenuGroup) -> impl Iterator } pub fn shortcut_label(action: UiAction) -> Option { + shortcut_label_for(action, DesktopPlatform::current()) +} + +pub fn shortcut_label_for(action: UiAction, platform: DesktopPlatform) -> Option { let shortcut = spec_for(action).shortcut?; - if cfg!(target_os = "macos") { + if platform == DesktopPlatform::MacOs { Some(shortcut.to_owned()) } else { Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+")) @@ -685,13 +715,21 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { } pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option { + shortcut_action_for(DesktopPlatform::current(), key, modifiers) +} + +pub fn shortcut_action_for( + platform: DesktopPlatform, + key: &keyboard::Key, + modifiers: keyboard::Modifiers, +) -> Option { 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() { + if !modifiers.contains(platform.primary_modifier()) { return None; } match key.as_ref() { @@ -940,36 +978,62 @@ mod tests { } #[test] - fn conventional_shortcuts_resolve_to_the_registered_actions() { - let primary = keyboard::Modifiers::COMMAND; - for (key, expected) in [ - ("o", UiAction::OpenFolder), - ("s", UiAction::Save), - ("f", UiAction::Find), - ("k", UiAction::CommandPalette), - ("n", UiAction::NewEntry), - ("w", UiAction::CloseWindow), - ("q", UiAction::Quit), - ("x", UiAction::Cut), - ("c", UiAction::CopyField), - ("v", UiAction::Paste), - (",", UiAction::Settings), + fn conventional_shortcuts_resolve_on_every_supported_platform() { + for (platform, primary, label) in [ + (DesktopPlatform::MacOs, keyboard::Modifiers::LOGO, "⌘S"), + (DesktopPlatform::Linux, keyboard::Modifiers::CTRL, "Ctrl+S"), + ( + DesktopPlatform::Windows, + keyboard::Modifiers::CTRL, + "Ctrl+S", + ), ] { assert_eq!( - shortcut_action(&keyboard::Key::Character(key.into()), primary), - Some(expected) + shortcut_label_for(UiAction::Save, platform).as_deref(), + Some(label) + ); + for (key, expected) in [ + ("o", UiAction::OpenFolder), + ("s", UiAction::Save), + ("f", UiAction::Find), + ("k", UiAction::CommandPalette), + ("n", UiAction::NewEntry), + ("w", UiAction::CloseWindow), + ("q", UiAction::Quit), + ("x", UiAction::Cut), + ("c", UiAction::CopyField), + ("v", UiAction::Paste), + (",", UiAction::Settings), + ] { + assert_eq!( + shortcut_action_for(platform, &keyboard::Key::Character(key.into()), primary,), + Some(expected) + ); + } + assert_eq!( + shortcut_action_for( + platform, + &keyboard::Key::Character("f".into()), + primary | keyboard::Modifiers::SHIFT, + ), + Some(UiAction::SearchContents) + ); + assert_eq!( + shortcut_action_for( + platform, + &keyboard::Key::Named(Named::F1), + keyboard::Modifiers::NONE, + ), + Some(UiAction::Help) ); } assert_eq!( - shortcut_action( - &keyboard::Key::Character("f".into()), - primary | keyboard::Modifiers::SHIFT, + shortcut_action_for( + DesktopPlatform::MacOs, + &keyboard::Key::Character("s".into()), + keyboard::Modifiers::CTRL, ), - Some(UiAction::SearchContents) - ); - assert_eq!( - shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE), - Some(UiAction::Help) + None ); assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1")); } diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 909707d..d86d8db 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -852,14 +852,18 @@ fn main() -> iced::Result { .title(ironstorage::PRODUCT_NAME) .subscription(App::subscription) .exit_on_close_request(false) - .window(window::Settings { - size: Size::new(1_080.0, 720.0), - min_size: Some(Size::new(720.0, 480.0)), - ..window::Settings::default() - }) + .window(window_settings()) .run() } +fn window_settings() -> window::Settings { + window::Settings { + size: Size::new(1_080.0, 720.0), + min_size: Some(Size::new(720.0, 480.0)), + ..window::Settings::default() + } +} + impl App { fn new() -> (Self, Task) { let mut app = Self { @@ -3345,11 +3349,8 @@ impl App { .min_size(220) .on_resize(8, Message::PaneResized); - let shortcut = if cfg!(target_os = "macos") { - "⌘K" - } else { - "Ctrl+K" - }; + let shortcut = action::shortcut_label(UiAction::CommandPalette) + .expect("the command palette has a registered shortcut"); let command_input = text_input( &format!("Search commands ({shortcut})"), self.palette.query(), @@ -5034,6 +5035,89 @@ mod tests { use super::*; + #[test] + fn desktop_coverage_matrix_contains_every_registered_action() { + let matrix = include_str!("../../../docs/desktop-audit.md"); + for spec in action::ACTIONS { + assert!( + matrix.contains(&format!("`{}`", spec.action.id())), + "desktop parity matrix is missing {}", + spec.action.id() + ); + } + for required_surface in [ + "Base pass", + "Pass OTP", + "Embedded Git", + "Configuration and lock", + "macOS", + "Linux", + "Windows", + ] { + assert!( + matrix.contains(required_surface), + "missing {required_surface}" + ); + } + } + + #[test] + fn production_desktop_sources_preserve_security_and_architecture_boundaries() { + let sources = [ + ("action.rs", include_str!("action.rs")), + ("editor.rs", include_str!("editor.rs")), + ("folder_picker.rs", include_str!("folder_picker.rs")), + ("main.rs", include_str!("main.rs")), + ("native_menu.rs", include_str!("native_menu.rs")), + ("navigation.rs", include_str!("navigation.rs")), + ("palette.rs", include_str!("palette.rs")), + ]; + for (name, source) in sources { + let production = source + .split("#[cfg(test)]\nmod tests") + .next() + .unwrap_or(source); + let forbidden_tokens = [ + ["std::", "process"].concat(), + ["process", "::Command"].concat(), + ["Command", "::new("].concat(), + ["Repository", "::open"].concat(), + ["GitRepository", "::"].concat(), + ["OtpUri", "::parse"].concat(), + ["qrcode", "::QrCode"].concat(), + ["fs::", "write"].concat(), + ["File", "::create"].concat(), + ["OpenOptions", "::new"].concat(), + ["println", "!("].concat(), + ["eprintln", "!("].concat(), + ["dbg", "!("].concat(), + ["log", "::info"].concat(), + ["log", "::debug"].concat(), + ["log", "::error"].concat(), + ["tracing", "::info"].concat(), + ["tracing", "::debug"].concat(), + ["tracing", "::error"].concat(), + ["http", "://"].concat(), + ["unsafe", " {"].concat(), + ]; + for forbidden in &forbidden_tokens { + assert!( + !production.contains(forbidden), + "{name} crosses the desktop architecture boundary with {forbidden}" + ); + } + } + } + + #[test] + fn window_contract_keeps_both_scrollable_panes_usable_at_narrow_size() { + let settings = window_settings(); + assert_eq!(settings.size, Size::new(1_080.0, 720.0)); + assert_eq!(settings.min_size, Some(Size::new(720.0, 480.0))); + let app = test_app(None); + let _view = app.view(); + } + #[derive(Clone, Default)] struct ManualClock(Arc>); diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index f792ab0..5d4e9f6 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -160,3 +160,35 @@ fn accelerator(action: UiAction) -> Option { }; Some(Accelerator::new(Some(modifiers), code)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_menu_uses_standard_macos_accelerators() { + for (action, modifiers, code) in [ + (UiAction::Settings, Modifiers::SUPER, Code::Comma), + (UiAction::NewEntry, Modifiers::SUPER, Code::KeyN), + (UiAction::OpenFolder, Modifiers::SUPER, Code::KeyO), + (UiAction::Save, Modifiers::SUPER, Code::KeyS), + (UiAction::CloseWindow, Modifiers::SUPER, Code::KeyW), + (UiAction::Quit, Modifiers::SUPER, Code::KeyQ), + (UiAction::Find, Modifiers::SUPER, Code::KeyF), + ( + UiAction::SearchContents, + Modifiers::SUPER | Modifiers::SHIFT, + Code::KeyF, + ), + (UiAction::CommandPalette, Modifiers::SUPER, Code::KeyK), + (UiAction::Refresh, Modifiers::SUPER, Code::KeyR), + (UiAction::Lock, Modifiers::SUPER, Code::KeyL), + (UiAction::Help, Modifiers::empty(), Code::F1), + ] { + let accelerator = accelerator(action).expect("registered native accelerator"); + assert_eq!(accelerator.modifiers(), modifiers, "{action:?}"); + assert_eq!(accelerator.key(), code, "{action:?}"); + } + assert!(accelerator(UiAction::GeneratePassword).is_none()); + } +} diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md new file mode 100644 index 0000000..69aa653 --- /dev/null +++ b/docs/desktop-audit.md @@ -0,0 +1,121 @@ +# Desktop parity, platform, and security audit + +The Iced application is a presentation adapter over `crates/storage`. The +tables below are the milestone 01/02 coverage record. `action.rs` is the single +desktop action and enablement registry used by menus, shortcuts, direct +controls, context controls, Help, and the command palette. A desktop unit test +requires every registered action ID to remain present in this document. + +## Coverage matrix + +| Area and compatible operation | Registered desktop action and menu | Direct control, dialog, or view | Command palette | +| --- | --- | --- | --- | +| Configuration and lock: open configured store | `open-folder` (File) | Open Folder button and native folder picker; storage validates and persists configuration | Yes | +| Configuration and lock: edit shared settings | `settings` (IronStorage) | Labelled Settings form for vault, default key, and inactivity timeout | Yes | +| Configuration and lock: refresh typed tree | `refresh` (View) | Sidebar Refresh button | Yes | +| Configuration and lock: lock/unlock | `lock` (Tools) | Lock button; opening protected content starts storage authentication | Lock only; unlock is the protected action being resumed | +| Base pass: `init` root or nested recipient policy | `initialize-store`, `new-folder` (File) | Recipient/default-key form and explicit replacement confirmation | Yes | +| Base pass: default/list/`ls`/`list` | `refresh` (View) | Expandable, scrollable storage-provided sidebar tree | Yes for refresh; browsing is direct navigation | +| Base pass: `show` and reload | `open-entry`, `reload-entry` (Entry) | Entry path control, tree activation, structured viewer, Reload button | Yes | +| Base pass: `insert`/`add` | `new-entry` (File) | New Entry form creates a lossless draft, then the structured editor saves it | Yes | +| Base pass: `edit` and save | `edit-entry`, `save` (Entry/File) | Edit and Save buttons; shared Save/Discard/Cancel guard | Yes | +| Base pass: `generate` and replace | `generate-password` (Entry) | Field Generate control and explicit replacement confirmation | Yes | +| Base pass: explicit secret display and clipboard | `toggle-reveal`, `copy-field`, `copy-edited-field` (Entry) | Per-field labelled Reveal/Hide and Copy controls | Yes | +| Base pass: `find` | `find` (Edit) | Name-search form and typed result activation | Yes | +| Base pass: `grep` | `search-contents` (Edit) | Authenticated decrypted-search form and typed result activation | Yes | +| Base pass: `mv`/`rename`, `cp`/`copy`, `rm`/`remove` | `move-entry`, `copy-entry`, `delete-entry` (Entry) | Sidebar context controls and validated mutation forms; delete is confirmed | Yes | +| Pass OTP: code/show and timed copy | `generate-otp`, `copy-otp` (Entry) | OTP panel shows typed metadata, code, validity, and HOTP confirmation | Yes | +| Pass OTP: insert/add/append and validate | `import-otp` (Entry) | URI/QR import form; storage validates, replaces, and commits | Yes | +| Pass OTP: URI terminal/clipboard/QR | `show-otp-uri`, `copy-otp-uri`, `show-otp-qr` (Entry) | Explicit secret view, timed copy, and storage-provided QR matrix | Yes | +| Pass OTP: remove | `remove-otp` (Entry) | Explicit permanent-removal confirmation | Yes | +| Embedded Git: status, history, remote divergence | `git-status` (Tools) | Git dashboard renders the storage snapshot and recent history | Yes | +| Embedded Git: fetch/pull | `git-pull` (Tools) | Pull progress/cancel control; fetch is the receive phase of the typed pull | Yes | +| Embedded Git: push | `git-push` (Tools) | Push progress/cancel control | Yes | +| Embedded Git: sync and conflicts | `git-sync` (Tools) | Sync progress plus per-path local/remote conflict choices | Yes | +| Desktop command discovery and pane navigation | `command-palette`, `toggle-pane-focus` (View) | Toolbar search, Tab focus transfer, arrows/Home/End/Enter navigation | The palette opens itself by shortcut/control and is intentionally not a result | +| Desktop text editing | `undo`, `redo`, `cut`, `paste` (Edit) | Platform text controls own these standard operations; the shared registry explains why app-level dispatch is disabled | Listed with its current availability | +| Window/application lifecycle | `about`, `close-window`, `quit`, `minimize` (IronStorage/File/Window) | Standard native roles on macOS and equivalent in-window actions elsewhere | Yes except native-only minimize behavior where the OS owns the role | +| Help and current shortcuts | `help` (Help) | Labelled, scrollable Help view generated from the action registry | Yes | + +The CLI/TUI expose a few terminal-shaped selectors rather than separate domain +capabilities. Generic `show --qrcode`, OTP version output, arbitrary embedded +Git plumbing, and shell completion are therefore not duplicate desktop +actions. The desktop viewer already renders an ordinary field, OTP version +compatibility belongs to storage/CLI metadata, Git initialization and commits +occur within typed storage mutations, remote configuration is shared Settings, +and desktop command discovery is the palette. Git history and conflict +resolution are controls inside `git-status` rather than separate commands. +These are deliberate presentation differences, not storage-feature gaps. + +## Interaction and accessibility evidence + +- Keyboard-only operation uses Tab between panes, arrows/Home/End/Enter within + trees, fields, menus, and palette results, Escape to dismiss, and registered + accelerators. The same controls are mouse/touch activatable; sidebar context + actions also accept a secondary click. +- Both panes are independently scrollable, the divider is resizable, action + rows wrap, long names and multiline values are retained, and the supported + narrow window floor is 720 by 480 logical pixels. Iced/winit applies native + display scaling before layout. +- Interactive controls use visible, operation-specific text instead of icon- + only labels. Focused/selected controls use the theme's primary contrast pair; + light, dark, and operating-system high-contrast palettes retain a visible + text label as a non-colour focus cue. +- The app implements no animation or motion-driven state transition. The one- + second subscription updates lease, OTP, Git, and clipboard presentation state + without moving focus or renewing authentication, so reduced-motion mode has + no additional transition to disable. +- Iced 0.14 does not expose a stable application API for supplying a native + screen-reader accessibility tree. Controls therefore have complete visible + labels and deterministic keyboard focus, but native screen-reader role/name + integration is an explicit framework limitation rather than a silent stub. + +## Platform menu and workflow smoke contract + +`conventional_shortcuts_resolve_on_every_supported_platform` executes the +Command-versus-Control mapping for macOS, Linux, and Windows on every test host. +The macOS-only native adapter test verifies its native accelerators. Linux and +Windows use the same action registry through the in-window menu, so its labels, +enablement, palette dispatch, and workflow state tests are platform-neutral. + +Native equivalent runners use stable Rust and run from the repository root: + +```sh +cargo test --package ironstorage-desktop --all-targets +cargo check --package ironstorage-desktop --all-targets +cargo run --package ironstorage-desktop +``` + +For the final smoke step on each native host: + +| Platform | Menu/accelerator smoke | Primary workflow smoke | +| --- | --- | --- | +| macOS | System App/File/Edit/View/Entry/Tools/Window/Help menus; Command-O/S/F/K/L, Tab, F1 | Open a test vault, unlock/view/edit/save, search, Git refresh, OTP code/copy, lock, close | +| Linux | In-window menus; Ctrl-O/S/F/K/L, Tab, F1 | Same sequence under X11 or Wayland with the desktop portal available | +| Windows | In-window menus; Ctrl-O/S/F/K/L, Tab, F1 | Same sequence with the native folder picker and Windows secure store available | + +The automated state tests use temporary real encrypted stores and cover the +same primary workflows without retaining a GUI password or starting a helper. +Native picker, clipboard, secure-store, and actual screen-reader integration +remain native-host smoke checks because CI cannot emulate those OS services. + +## Security and architecture audit + +| Risk | Enforced behavior and executable evidence | +| --- | --- | +| Plaintext lifetime and persistence | Entry/OTP values use storage `SecretBytes` or zeroizing edit buffers. Lock, expiry, vault switch, and stale completion paths drop the editor and sensitive presentation state. The source audit rejects desktop filesystem writes. | +| Masking, errors, and diagnostics | Storage supplies sensitivity and redacted typed errors. Viewer/editor tests require masking until explicit reveal; malformed and non-UTF-8 fields remain lossless. Desktop messages are not `Debug`, and the source audit rejects print/debug/log-style output. | +| Clipboard | `NativeClipboardManager` owns timeout and replacement-safe cleanup. Desktop state shows a live remaining-seconds value, cancels cleanup on lock, and ignores stale completions. | +| Authentication expiry | Storage authentication leases own the clock and policy. Passive ticks, rendering, pointer movement, and window events do not renew activity; deterministic tests cover expiry during protected state. | +| Dirty documents and conflicts | Every entry/vault/window/Git worktree replacement routes through one Save/Discard/Cancel decision. Failed saves and conflicts keep the complete draft. | +| Background and window lifecycle | Generation counters reject stale asynchronous results. Lock cancels Git/clipboard work and clears OTP, QR, URI, entry, and editor state. Close and quit use the same dirty guard. | +| Repository and domain ownership | The executable source audit rejects repository/Git construction, process launch, OTP/QR parsing, filesystem writes, unsafe blocks, and non-HTTPS literals in production desktop modules. The folder picker may read only a user-selected QR image; all password-store I/O remains in `crates/storage`. | + +Run the complete repository gate after the desktop-specific checks: + +```sh +cargo fmt --all -- --check +RUSTFLAGS="-D warnings" cargo check --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +```