diff --git a/Cargo.lock b/Cargo.lock index a162843..65fba2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index a0fc6a3..b7a55d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 03b4042..1754247 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -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. | diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 2f78bb8..4ca0a3a 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -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" diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs new file mode 100644 index 0000000..f5b149b --- /dev/null +++ b/apps/desktop/src/action.rs @@ -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 { + ACTIONS.iter().filter(move |spec| spec.group == group) +} + +pub fn shortcut_label(action: UiAction) -> Option { + 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 { + 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::>(); + 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) + ); + } + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 4a4748a..7c1aa40 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -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>>>; type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; #[derive(Clone)] enum Message { + Action(UiAction), + FieldAction(EntryFieldId, UiAction), + ToggleMenu(MenuGroup), + DismissUtility, + WindowResolved(UiAction, Option), + #[cfg(target_os = "macos")] + PollNativeMenu, StartupLoaded(Box>), TreeLoaded { generation: u64, completion: TreeCompletion, }, - RefreshTree, SidebarActivate(TreeNodeId), SidebarNavigate(NavigationKey), TogglePaneFocus, @@ -83,7 +96,6 @@ enum Message { generation: u64, result: Result, }, - Save, SaveFinished { generation: u64, completion: SaveCompletion, @@ -163,6 +175,10 @@ struct App { generate_confirmation: Option, conflict: bool, status: String, + open_menu: Option, + utility: Option, + #[cfg(target_os = "macos")] + native_menu: Option, } #[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) { + 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 { 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 { + 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 { let Some(storage) = self.storage.clone() else { return Task::none(); @@ -810,20 +945,30 @@ impl App { } fn subscription(&self) -> Subscription { - 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( fn event_message(event: &Event) -> Option { 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)) )); } diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs new file mode 100644 index 0000000..400ea8e --- /dev/null +++ b/apps/desktop/src/native_menu.rs @@ -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 { + 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 { + 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 { + 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)) +} diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index ff02df0..0a9a858 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -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 { let repository = self.repository()?; let keys = self.keys()?;