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

540
Cargo.lock generated
View File

@@ -473,6 +473,29 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "atk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"libc",
]
[[package]]
name = "atk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "atomic"
version = "0.6.1"
@@ -621,6 +644,9 @@ name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
@@ -772,6 +798,31 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cairo-rs"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.1",
"cairo-sys-rs",
"glib",
"libc",
"once_cell",
"thiserror 1.0.69",
]
[[package]]
name = "cairo-sys-rs"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "calloop"
version = "0.13.0"
@@ -958,6 +1009,16 @@ dependencies = [
"cipher",
]
[[package]]
name = "cfg-expr"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -1028,7 +1089,7 @@ version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
@@ -2005,6 +2066,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -2022,6 +2092,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -2334,6 +2414,64 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b"
[[package]]
name = "gdk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk-pixbuf"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
"once_cell",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -2404,6 +2542,38 @@ dependencies = [
"polyval",
]
[[package]]
name = "gio"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"once_cell",
"pin-project-lite",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "gio-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"winapi",
]
[[package]]
name = "gix"
version = "0.86.0"
@@ -3248,6 +3418,53 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
[[package]]
name = "glib"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.1",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"once_cell",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
dependencies = [
"heck 0.4.1",
"proc-macro-crate 2.0.2",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.4"
@@ -3275,6 +3492,17 @@ dependencies = [
"gl_generator",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "goblin"
version = "0.8.2"
@@ -3348,6 +3576,58 @@ dependencies = [
"subtle",
]
[[package]]
name = "gtk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
dependencies = [
"atk",
"cairo-rs",
"field-offset",
"futures-channel",
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"gtk-sys",
"gtk3-macros",
"libc",
"pango",
"pkg-config",
]
[[package]]
name = "gtk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
dependencies = [
"atk-sys",
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk3-macros"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "guillotiere"
version = "0.6.2"
@@ -3458,6 +3738,12 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -4037,6 +4323,7 @@ version = "0.1.0"
dependencies = [
"iced",
"ironstorage",
"muda",
"tempfile",
"zeroize",
]
@@ -4239,6 +4526,17 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.1",
"serde",
"unicode-segmentation",
]
[[package]]
name = "keyring-core"
version = "1.0.0"
@@ -4324,6 +4622,25 @@ dependencies = [
"redox_syscall 0.9.0",
]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]]
name = "lilt"
version = "0.8.2"
@@ -4525,6 +4842,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "muda"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
dependencies = [
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
[[package]]
name = "mundy"
version = "0.2.3"
@@ -4779,7 +5117,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -5308,6 +5646,31 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "pango"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"libc",
"once_cell",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -5619,6 +5982,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.1",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -5699,13 +6075,57 @@ dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
dependencies = [
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.13+spec-1.1.0",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
@@ -6589,6 +7009,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -6882,7 +7311,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -6993,7 +7422,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -7084,12 +7513,31 @@ dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -7361,6 +7809,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -7369,7 +7829,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -7384,13 +7844,22 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -7409,6 +7878,30 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
@@ -7666,7 +8159,7 @@ dependencies = [
"fs-err",
"glob",
"goblin",
"heck",
"heck 0.5.0",
"indexmap",
"once_cell",
"serde",
@@ -7740,7 +8233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948"
dependencies = [
"anyhow",
"heck",
"heck 0.5.0",
"indexmap",
"tempfile",
"uniffi_internal_macros",
@@ -7811,6 +8304,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8699,6 +9198,15 @@ dependencies = [
"xkbcommon-dl",
]
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"
@@ -8763,6 +9271,16 @@ dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -8920,7 +9438,7 @@ version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [
"proc-macro-crate",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -9072,7 +9590,7 @@ version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [
"proc-macro-crate",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",

View File

@@ -30,6 +30,7 @@ hmac = "0.12"
iced = { version = "0.14", features = ["tokio"] }
ironstorage = { path = "crates/storage" }
keyring-core = "1.0"
muda = "0.19"
pgp = { version = "0.20", default-features = false }
qrcode = { version = "0.14", default-features = false }
rand = "0.8"

View File

@@ -18,6 +18,7 @@ The current direct dependencies are:
| [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT |
| [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT |
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
| [muda 0.19](https://crates.io/crates/muda/0.19.3) | Safe native macOS application menus and standard roles | MIT OR Apache-2.0 |
| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 |
| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 |
| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 |
@@ -53,6 +54,7 @@ decision.
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
| TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. |
| Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. |
| Native macOS menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3) | MIT OR Apache-2.0 | Selected only on macOS. Its safe `NSApp` adapter supplies standard roles and accelerators; Linux and Windows use the same action registry through an in-window Iced menu bar, avoiding GTK and Win32 integration dependencies. |
| CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. |
| Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. |
| QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | `qrcode` selected without image features for storage-owned matrices and terminal rendering. `rqrr` is test-only round-trip verification. Apple camera scanning should use AVFoundation and pass only decoded bytes to Rust. |

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

View File

@@ -3,7 +3,9 @@
use std::{error::Error, fmt, path::Path};
use crate::{
authentication::{NativeAuthenticationHandle, NativeAuthenticationSession},
authentication::{
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
},
config::Config,
crypto::{KeyInfo, KeyStore, SecretProvider},
document::{DocumentError, EntryDocument, EntryDocumentService},
@@ -111,6 +113,10 @@ impl DesktopStorage {
self.config.clipboard_timeout()
}
pub fn authentication_timeout(&self) -> AuthenticationTimeout {
self.config.authentication_timeout()
}
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;