diff --git a/AGENTS.md b/AGENTS.md index 5f6b4b2..710da57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,13 @@ - Keep changes focused, handle errors explicitly, and add the smallest useful test for non-trivial behavior. - Preserve `rustfmt` output and keep Clippy warning-free. +## Visual language + +- Follow the Codex macOS dark UI: near-black backgrounds, subtly raised surfaces, quiet borders, rounded corners, and restrained contrast. +- Present overviews as one bordered panel with divided rows, a clear primary label, muted supporting text, and compact trailing actions. +- Reuse the shared surface and action-button styles in `src/app/view.rs`; keep button shape, padding, typography, hover, and disabled states consistent across windows. Destructive actions may differ by color only, while navigation controls may remain flat. +- Prefer generous spacing and clear hierarchy over decoration; avoid one-off colors, card stacks, oversized controls, and screen-specific button styling. + ## Commit gates Run before every commit: diff --git a/Cargo.lock b/Cargo.lock index debc301..6fa93d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,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-waker" version = "1.1.2" @@ -343,6 +366,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -437,6 +463,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" @@ -500,6 +551,16 @@ dependencies = [ "shlex", ] +[[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" @@ -719,6 +780,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1066,6 +1136,8 @@ dependencies = [ "diesel", "diesel_migrations", "iced", + "muda", + "png", "rfd", "sha2", "ureq", @@ -1079,7 +1151,7 @@ checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ "darling", "either", - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1207,6 +1279,16 @@ dependencies = [ "simd-adler32", ] +[[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 = "find-msvc-tools" version = "0.1.9" @@ -1424,6 +1506,64 @@ dependencies = [ "slab", ] +[[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" @@ -1488,6 +1628,38 @@ dependencies = [ "weezl", ] +[[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 = "gl_generator" version = "0.14.0" @@ -1505,6 +1677,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 = "glow" version = "0.13.1" @@ -1526,6 +1745,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 = "gpu-alloc" version = "0.6.2" @@ -1578,6 +1808,58 @@ dependencies = [ "bitflags 2.13.1", ] +[[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" @@ -1648,6 +1930,12 @@ dependencies = [ "winapi", ] +[[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" @@ -1741,6 +2029,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.3", + "tokio", "wasm-bindgen-futures", "wasm-timer", ] @@ -2102,6 +2391,17 @@ dependencies = [ "wasm-bindgen", ] +[[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 = "khronos-egl" version = "6.0.0" @@ -2194,6 +2494,25 @@ dependencies = [ "vcpkg", ] +[[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 = "linux-raw-sys" version = "0.4.15" @@ -2294,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", - "toml", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -2318,6 +2637,26 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "muda" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdae9c00e61cc0579bcac625e8ad22104c60548a025bfc972dc83868a28e1484" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", + "png", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + [[package]] name = "naga" version = "0.19.2" @@ -2421,7 +2760,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", @@ -2797,6 +3136,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" @@ -3029,13 +3393,57 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[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]] @@ -3524,6 +3932,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" @@ -3877,6 +4294,25 @@ 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 = "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" @@ -4033,6 +4469,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "pin-project-lite", +] + +[[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" @@ -4040,12 +4497,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", - "serde_spanned", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.15", ] +[[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" @@ -4064,6 +4530,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" @@ -4327,6 +4817,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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" @@ -4924,6 +5420,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" @@ -4960,6 +5465,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[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" @@ -5147,7 +5662,7 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5160,7 +5675,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", @@ -5311,7 +5826,7 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5324,7 +5839,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 a8653fd..1eeb3a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,15 @@ publish = false [dependencies] diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } diesel_migrations = "2.3.2" -iced = { version = "0.13.1", features = ["svg"] } +iced = { version = "0.13.1", features = ["svg", "tokio"] } +png = "0.17.16" rfd = "0.15.4" sha2 = "0.11.0" ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } +[target.'cfg(target_os = "macos")'.dependencies] +muda = "0.15.3" + [package.metadata.packager] product-name = "DS4Server" identifier = "DS4Server.rfc1437.de" diff --git a/PLAN.md b/PLAN.md index eb0f213..d6237f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,10 +12,10 @@ sessions, tools, and turn orchestration. | Area | Status | Available now | Still open | | --- | --- | --- | --- | -| App shell | Implemented | Native Iced window, Codex-inspired two-pane layout, dark theme, embedded SVG icons, `Command-,` Preferences panel | Functional chat content and native app packaging | +| App shell | Implemented | Native multi-window Iced app, Codex-inspired layout, native Application/Edit/Window menus, Preferences panel, and Model Manager window | Functional chat content and native app packaging | | Projects and sessions | Implemented for metadata | Native folder picker, project-name dialog, create/select/delete projects and sessions | Transcripts, KV payloads, rename/archive, and model binding | | Persistence | Implemented for metadata and preferences | SQLite in Application Support, Diesel ORM, embedded migrations, constrained defaults and CRUD tests | Messages, downloaded-artifact metadata, and session-runtime migrations | -| Model runtime | Download workflow implemented | Persisted model choice, DSpark toggle, idle timeout, and Rust-native background main/DSpark downloads with progress, ETA, stop, restart-resume, size checks, and SHA-256 verification | Loading, inference, idle unloading, and Metal integration | +| Model runtime | Download workflow implemented | Persisted model choice, DSpark toggle, idle timeout, and a Model Manager for background download, validation, deletion, progress, ETA, stop, and restart-resume | Loading, inference, idle unloading, and Metal integration | | Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces | | Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation | | A2UI | Future extension | bDS2 provides the reference structured render-tool contract | Native inline surfaces for local chat after core chat and tools are stable | @@ -23,9 +23,10 @@ sessions, tools, and turn orchestration. | Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization | The application currently manages durable project/session metadata and model -preferences, and can download the selected model's main and optional DSpark -GGUF into managed Application Support storage without blocking the UI. It does -not yet load a model, serve HTTP, save chat messages, or run an agent. +preferences. Its separate Model Manager can download, validate, and delete +main and DSpark GGUF artifacts in managed Application Support storage without +blocking the UI. It does not yet load a model, serve HTTP, save chat messages, +or run an agent. ## Phase 0 — project and session shell @@ -204,17 +205,23 @@ chat or HTTP code re-parsing preference fields. ### Targeted downloads and storage -- The Preferences panel can download the selected model's main GGUF and the - DSpark support GGUF when enabled. A dedicated worker thread performs HTTP and - verification work so Preferences and the rest of the application stay - interactive. +- Preferences only selects the model/runtime configuration. A separate Model + Manager window, reachable from the native Window menu, lists every managed + main and DSpark artifact with its on-disk size and state, and provides + Download/Resume, Validate, Stop, and confirmed Delete actions. +- A dedicated worker thread performs HTTP and verification work so the Model + Manager and the rest of the application stay interactive. - Use an in-process Rust HTTP client with Rustls rather than invoking a system downloader. Stream response chunks directly into a `.part` file and use a Rust SHA-256 implementation for verification. - Report aggregate bytes present, total bytes, bytes missing, percentage, smoothed transfer rate, current artifact/verification phase, and estimated - time remaining. Keep the detailed display in Preferences and a persistent + time remaining. Keep the detailed display in Model Manager and a persistent status bar with a Stop action while the transfer runs. +- During SHA-256 verification, show a dedicated progress bar based on bytes + read and hashed rather than the already-complete file size. Update the bytes + verified, verification throughput, percentage, and estimated time remaining + throughout the pass so large models never appear stalled at 100%. - Stop is non-destructive: close the active transfer and retain its `.part` file. Starting the download again in the same process or after quitting and relaunching must derive progress from that file and issue an HTTP range @@ -228,15 +235,13 @@ chat or HTTP code re-parsing preference fields. `~/Library/Application Support/DS4Server.rfc1437.de/models//` and mmap/load them from there. Do not place managed models in the app bundle or a project directory. -- Download only the selected model's main artifact. Download its DSpark - artifact only when DSpark is enabled; never prefetch other catalog models or - optional artifacts. +- Download only the artifact explicitly requested in Model Manager; never + prefetch other catalog models or optional artifacts. - Remove checksum-invalid partial files without touching a valid model. Keep partial files after cancellation, application exit, network failure, or an interrupted verification so they remain restart-resumable. -- When the selected model changes or DSpark is disabled, offer removal of the - now-unused artifact; do not delete large existing downloads without explicit - confirmation. +- Delete model artifacts only through an explicit, confirmed Model Manager + action; changing Preferences must never remove files. Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without blocking the UI only when a chat begins, run a deterministic prompt, unload it diff --git a/README.md b/README.md index 109f602..4280929 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ The current milestone provides a Codex-inspired project/session layout. A native macOS folder picker selects each workspace, then the app asks for its display name. Projects, sessions, and model preferences are persisted through Diesel in SQLite. Open Preferences with `Command-,` to select the model, toggle DSpark, -configure the model idle timeout, and download the selected main GGUF with -its optional DSpark support. Rust-native background downloads show live byte -progress, speed, and ETA in Preferences and the app status bar. Stopping or -quitting keeps the partial file; the next Download/Resume action continues from -that exact byte after relaunch. Exact size and SHA-256 verification happen -before an artifact becomes usable. +and configure the model idle timeout. The separate Model Manager (`Shift-Command-M`) +lists local main and DSpark artifacts, their on-disk sizes and state, and lets +you download, resume, validate, or delete them. Rust-native background work +shows live byte progress, speed, and ETA in Model Manager and the app status +bar. Stopping or quitting keeps the partial file; the next Download/Resume +action continues from that exact byte after relaunch. Exact size and SHA-256 +verification happen before an artifact becomes usable. ```sh cargo install cargo-packager --locked --version 0.11.8 diff --git a/assets/DS4Server.icns b/assets/DS4Server.icns index 52bf65b..335e36d 100644 Binary files a/assets/DS4Server.icns and b/assets/DS4Server.icns differ diff --git a/src/app.rs b/src/app.rs index 63cdb6f..45d4c8f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,14 +3,13 @@ mod view; pub(crate) use view::app_theme; use crate::database::{AppPreferences, Database, ProjectWithSessions}; -use crate::model::{self, DownloadOutcome, DownloadProgress, ModelChoice}; -use iced::futures::{SinkExt, Stream}; -use iced::{Subscription, Task, keyboard}; +use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice}; +use iced::{Size, Subscription, Task, keyboard, window}; use rfd::AsyncFileDialog; use std::fs; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, TryRecvError}; use std::thread; use std::time::{Duration, Instant}; @@ -37,6 +36,11 @@ impl PreferenceDraft { } pub(crate) struct App { + main_window: window::Id, + pub(super) model_manager_window: Option, + pub(super) pending_model_delete: Option, + #[cfg(target_os = "macos")] + _native_menu: Option, database: Option, projects: Vec, preferences: AppPreferences, @@ -56,18 +60,25 @@ pub(crate) struct App { pub(super) enum ModelDownload { Idle, Active(ActiveDownload), - Complete(ModelChoice, DownloadProgress), - Failed(ModelChoice, String, DownloadProgress), + Complete(ManagedArtifactId, ModelOperation, DownloadProgress), + Failed(ManagedArtifactId, String, DownloadProgress), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ModelOperation { + Download, + Validate, } #[derive(Debug)] pub(super) struct ActiveDownload { - model: ModelChoice, - dspark_enabled: bool, + artifact: ManagedArtifactId, + operation: ModelOperation, progress: DownloadProgress, sampled_at: Instant, sampled_bytes: u64, bytes_per_second: f64, + verified_bytes: Arc, cancel: Arc, result: mpsc::Receiver>, stopping: bool, @@ -75,13 +86,22 @@ pub(super) struct ActiveDownload { #[derive(Debug, Clone)] pub(crate) enum Message { + Noop, OpenPreferences, + OpenModelManager, + ModelManagerOpened(window::Id), + WindowOpened(window::Id), + WindowClosed(window::Id), DismissPanel, PreferenceModelChanged(ModelChoice), PreferenceDsparkChanged(bool), PreferenceTimeoutChanged(String), SavePreferences, - DownloadSelectedModel, + DownloadArtifact(ManagedArtifactId), + ValidateArtifact(ManagedArtifactId), + DeleteArtifact(ManagedArtifactId), + ConfirmDeleteArtifact, + CancelDeleteArtifact, StopModelDownload, DownloadProgressTick, ChooseProjectFolder, @@ -97,16 +117,21 @@ pub(crate) enum Message { } impl App { - pub(crate) fn load() -> Self { + pub(crate) fn load(main_window: window::Id) -> Self { let path = application_support_path().join("data.sqlite3"); match Database::open(&path) { Ok(mut database) => match (database.load_projects(), database.load_preferences()) { (Ok(projects), Ok(preferences)) => { let preference_draft = match PreferenceDraft::from_saved(&preferences) { Ok(draft) => draft, - Err(error) => return Self::failed(error), + Err(error) => return Self::failed(error, main_window), }; Self { + main_window, + model_manager_window: None, + pending_model_delete: None, + #[cfg(target_os = "macos")] + _native_menu: None, database: Some(database), projects, preferences, @@ -122,17 +147,22 @@ impl App { error: None, } } - (Err(error), _) | (_, Err(error)) => Self::failed(error), + (Err(error), _) | (_, Err(error)) => Self::failed(error, main_window), }, - Err(error) => Self::failed(error), + Err(error) => Self::failed(error, main_window), } } - fn failed(error: String) -> Self { + fn failed(error: String, main_window: window::Id) -> Self { let preferences = AppPreferences::default(); let preference_draft = PreferenceDraft::from_saved(&preferences) .expect("default preferences must use a supported model"); Self { + main_window, + model_manager_window: None, + pending_model_delete: None, + #[cfg(target_os = "macos")] + _native_menu: None, database: None, projects: Vec::new(), preferences, @@ -151,7 +181,36 @@ impl App { pub(crate) fn update(&mut self, message: Message) -> Task { match message { + Message::Noop => {} Message::OpenPreferences => self.open_preferences(), + Message::OpenModelManager => return self.open_model_manager(), + Message::ModelManagerOpened(id) => { + if self.model_manager_window == Some(id) { + return window::gain_focus(id); + } + } + Message::WindowOpened(id) => + { + #[cfg(target_os = "macos")] + if id == self.main_window && self._native_menu.is_none() { + match crate::native_menu::install() { + Ok(menu) => self._native_menu = Some(menu), + Err(error) => { + self.error = + Some(format!("Could not install the application menu: {error}")) + } + } + } + } + Message::WindowClosed(id) => { + if id == self.main_window { + return iced::exit(); + } + if self.model_manager_window == Some(id) { + self.model_manager_window = None; + self.pending_model_delete = None; + } + } Message::DismissPanel => { if self.preferences_open { self.preferences_open = false; @@ -179,41 +238,27 @@ impl App { self.preference_error = None; } Message::SavePreferences => self.save_preferences(), - Message::DownloadSelectedModel => { - if matches!(self.model_download, ModelDownload::Active(_)) { - return Task::none(); - } - let model = self.preference_draft.model; - let dspark_enabled = - model.supports_dspark() && self.preference_draft.dspark_enabled; - let models_path = models_path(); - let progress = model::download_progress(model, dspark_enabled, &models_path); - let cancel = Arc::new(AtomicBool::new(false)); - let worker_cancel = Arc::clone(&cancel); - let (result_sender, result_receiver) = mpsc::channel(); - if let Err(error) = thread::Builder::new() - .name("model-download".to_owned()) - .spawn(move || { - let result = - model::download(model, dspark_enabled, &models_path, &worker_cancel); - let _ = result_sender.send(result); - }) - { - self.error = Some(format!("Could not start model download: {error}")); - return Task::none(); - } - self.model_download = ModelDownload::Active(ActiveDownload { - model, - dspark_enabled, - sampled_at: Instant::now(), - sampled_bytes: progress.downloaded, - progress, - bytes_per_second: 0.0, - cancel: Arc::clone(&cancel), - result: result_receiver, - stopping: false, - }); + Message::DownloadArtifact(artifact) => { + self.start_model_operation(artifact, ModelOperation::Download) } + Message::ValidateArtifact(artifact) => { + self.start_model_operation(artifact, ModelOperation::Validate) + } + Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact), + Message::ConfirmDeleteArtifact => { + if let Some(artifact) = self.pending_model_delete.take() { + match model::delete_managed_artifact(artifact, &models_path()) { + Ok(()) => { + self.model_download = ModelDownload::Idle; + self.error = None; + } + Err(error) => { + self.error = Some(format!("Could not delete {artifact}: {error}")) + } + } + } + } + Message::CancelDeleteArtifact => self.pending_model_delete = None, Message::StopModelDownload => { if let ModelDownload::Active(download) = &mut self.model_download { download.stopping = true; @@ -290,15 +335,105 @@ impl App { } pub(crate) fn subscription(&self) -> Subscription { - let shortcuts = keyboard::on_key_press(shortcut); + let mut subscriptions = vec![ + keyboard::on_key_press(shortcut), + window::close_requests().map(Message::WindowClosed), + window::close_events().map(Message::WindowClosed), + ]; + #[cfg(target_os = "macos")] + subscriptions.push(iced::time::every(Duration::from_millis(50)).map(|_| { + match crate::native_menu::next_event() { + Some(crate::native_menu::NativeMenuEvent::Preferences) => Message::OpenPreferences, + Some(crate::native_menu::NativeMenuEvent::ModelManager) => { + Message::OpenModelManager + } + None => Message::Noop, + } + })); if matches!(self.model_download, ModelDownload::Active(_)) { - Subscription::batch([ - shortcuts, - Subscription::run(download_ticks).map(|_| Message::DownloadProgressTick), - ]) - } else { - shortcuts + subscriptions.push( + iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick), + ); } + Subscription::batch(subscriptions) + } + + pub(crate) fn title(&self, id: window::Id) -> String { + if self.model_manager_window == Some(id) { + "Model Manager — DS4Server".to_owned() + } else { + "DS4Server".to_owned() + } + } + + fn open_model_manager(&mut self) -> Task { + if let Some(id) = self.model_manager_window { + return window::gain_focus(id); + } + let (id, open) = window::open(window::Settings { + size: Size::new(760.0, 560.0), + min_size: Some(Size::new(620.0, 420.0)), + icon: Some(app_icon()), + ..Default::default() + }); + self.model_manager_window = Some(id); + open.map(Message::ModelManagerOpened) + } + + fn start_model_operation(&mut self, artifact: ManagedArtifactId, operation: ModelOperation) { + if matches!(self.model_download, ModelDownload::Active(_)) { + return; + } + let models_path = models_path(); + let progress = match operation { + ModelOperation::Download => model::artifact_download_progress(artifact, &models_path), + ModelOperation::Validate => model::artifact_verification_progress(artifact, 0), + }; + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let verified_bytes = Arc::new(AtomicU64::new(0)); + let worker_verified_bytes = Arc::clone(&verified_bytes); + let (result_sender, result_receiver) = mpsc::channel(); + let thread_name = match operation { + ModelOperation::Download => "model-download", + ModelOperation::Validate => "model-validation", + }; + if let Err(error) = thread::Builder::new() + .name(thread_name.to_owned()) + .spawn(move || { + let result = match operation { + ModelOperation::Download => model::download_managed_artifact( + artifact, + &models_path, + &worker_cancel, + &worker_verified_bytes, + ), + ModelOperation::Validate => model::validate_managed_artifact( + artifact, + &models_path, + &worker_cancel, + &worker_verified_bytes, + ), + }; + let _ = result_sender.send(result); + }) + { + self.error = Some(format!("Could not start {thread_name}: {error}")); + return; + } + self.pending_model_delete = None; + self.model_download = ModelDownload::Active(ActiveDownload { + artifact, + operation, + sampled_at: Instant::now(), + sampled_bytes: progress.completed(), + progress, + bytes_per_second: 0.0, + verified_bytes, + cancel, + result: result_receiver, + stopping: false, + }); } fn open_preferences(&mut self) { @@ -443,11 +578,27 @@ impl App { return; }; let now = Instant::now(); - let progress = - model::download_progress(download.model, download.dspark_enabled, &models_path()); + let verified = download.verified_bytes.load(Ordering::Relaxed); + let mut progress = match download.operation { + ModelOperation::Download => { + model::artifact_download_progress(download.artifact, &models_path()) + } + ModelOperation::Validate => { + model::artifact_verification_progress(download.artifact, verified) + } + }; + if download.operation == ModelOperation::Download + && let Some(verification) = &mut progress.verification + { + verification.verified = verified.min(verification.total); + } let elapsed = now.duration_since(download.sampled_at).as_secs_f64(); - let transferred = progress.downloaded.saturating_sub(download.sampled_bytes); - if transferred > 0 && elapsed > 0.0 { + let completed = progress.completed(); + let phase_changed = progress.phase != download.progress.phase; + let transferred = completed.saturating_sub(download.sampled_bytes); + if phase_changed { + download.bytes_per_second = 0.0; + } else if transferred > 0 && elapsed > 0.0 { let current = transferred as f64 / elapsed; download.bytes_per_second = if download.bytes_per_second == 0.0 { current @@ -457,7 +608,7 @@ impl App { } download.progress = progress; download.sampled_at = now; - download.sampled_bytes = download.progress.downloaded; + download.sampled_bytes = completed; let result = match download.result.try_recv() { Ok(result) => Some(result), @@ -466,12 +617,14 @@ impl App { Some(Err("Download worker stopped unexpectedly.".into())) } }; - let model = download.model; + let artifact = download.artifact; + let operation = download.operation; let progress = download.progress.clone(); if let Some(result) = result { match result { Ok(DownloadOutcome::Complete) => { - self.model_download = ModelDownload::Complete(model, progress); + let progress = model::artifact_download_progress(artifact, &models_path()); + self.model_download = ModelDownload::Complete(artifact, operation, progress); self.error = None; } Ok(DownloadOutcome::Stopped) => { @@ -480,7 +633,7 @@ impl App { } Err(error) => { self.error = Some(error.clone()); - self.model_download = ModelDownload::Failed(model, error, progress); + self.model_download = ModelDownload::Failed(artifact, error, progress); } } } @@ -495,20 +648,12 @@ impl Drop for App { } } -fn download_ticks() -> impl Stream { - iced::stream::channel(1, |mut output| async move { - loop { - std::thread::sleep(Duration::from_secs(1)); - if output.send(()).await.is_err() { - break; - } - } - }) -} - fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option { match key.as_ref() { keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences), + keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => { + Some(Message::OpenModelManager) + } keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel), _ => None, } @@ -527,6 +672,24 @@ fn models_path() -> PathBuf { application_support_path().join("models") } +pub(crate) fn app_icon() -> window::Icon { + let decoder = png::Decoder::new(std::io::Cursor::new(include_bytes!( + "../assets/app-icon.png" + ))); + let mut reader = decoder + .read_info() + .expect("bundled application icon must be valid PNG"); + let mut rgba = vec![0; reader.output_buffer_size()]; + let info = reader + .next_frame(&mut rgba) + .expect("bundled application icon must decode"); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + rgba.truncate(info.buffer_size()); + window::icon::from_rgba(rgba, info.width, info.height) + .expect("bundled application icon dimensions must be valid") +} + #[cfg(test)] mod tests { use super::*; @@ -538,6 +701,11 @@ mod tests { keyboard::Modifiers::COMMAND, ); assert!(matches!(message, Some(Message::OpenPreferences))); + let message = shortcut( + keyboard::Key::Character("m".into()), + keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT, + ); + assert!(matches!(message, Some(Message::OpenModelManager))); assert!(ModelChoice::DeepSeekV4Flash.supports_dspark()); assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark()); assert!(!ModelChoice::Glm52.supports_dspark()); diff --git a/src/app/view.rs b/src/app/view.rs index 87000f5..a075d5d 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1,12 +1,14 @@ -use super::{ActiveDownload, App, Message, ModelDownload, models_path}; +use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path}; use crate::database::{ProjectWithSessions, Session}; -use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice}; +use crate::model::{ + self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, +}; use iced::theme::{Palette, palette}; use iced::widget::{ - Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row, - scrollable, stack, svg, text, text_input, + Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list, + progress_bar, row, scrollable, stack, svg, text, text_input, }; -use iced::{Alignment, Color, Element, Length, Theme}; +use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window}; use std::path::Path; const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg"); @@ -22,7 +24,15 @@ const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); impl App { - pub(crate) fn view(&self) -> Element<'_, Message> { + pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> { + if self.model_manager_window == Some(id) { + self.model_manager() + } else { + self.main_view() + } + } + + fn main_view(&self) -> Element<'_, Message> { let mut shell = column![ row![self.sidebar(), self.detail()] .width(Length::Fill) @@ -178,11 +188,9 @@ impl App { .spacing(8) .align_y(Alignment::Center); let open_project = if self.database.is_some() && !self.choosing_folder { - button(open_project_content) - .on_press(Message::ChooseProjectFolder) - .style(button::primary) + action_button(open_project_content).on_press(Message::ChooseProjectFolder) } else { - button(open_project_content) + action_button(open_project_content) }; return container( column![ @@ -237,7 +245,7 @@ impl App { .to_string(), ) .size(12), - button(icon(ICON_SEND, 18)), + action_button(icon(ICON_SEND, 18)).padding(8), ] .align_y(Alignment::Center), ] @@ -245,7 +253,7 @@ impl App { ) .padding(16) .width(Length::Fill) - .style(container::rounded_box), + .style(overview_style), ] .height(Length::Fill) .spacing(8); @@ -291,33 +299,6 @@ impl App { self.preference_draft.dspark_enabled, ) .on_toggle_maybe(dspark_toggle); - let model = self.preference_draft.model; - let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled; - let selected_progress = model::download_progress(model, dspark_enabled, &models_path()); - let installed = selected_progress.phase == DownloadPhase::Complete; - let download = if let ModelDownload::Active(download) = &self.model_download { - if download.stopping { - button("Stopping…") - } else { - button("Stop download") - .on_press(Message::StopModelDownload) - .style(button::danger) - } - } else if installed { - button("Downloaded") - } else { - let action = if selected_progress.downloaded > 0 { - "Resume" - } else { - "Download" - }; - button(text(format!( - "{action} {} ({})", - model, - format_bytes(selected_progress.total) - ))) - .on_press(Message::DownloadSelectedModel) - }; let mut content = column![ row![ @@ -338,13 +319,11 @@ impl App { .width(Length::Fill), dspark, text(if self.preference_draft.model.supports_dspark() { - "DSpark support is downloaded and used only when enabled." + "DSpark support is used only when enabled. Downloads are managed in Model Manager." } else { "DSpark is not available for the selected model." }) .size(12), - download.style(button::secondary), - model_download_status(&self.model_download, &selected_progress), Space::with_height(8), text("INACTIVITY").size(11), row![ @@ -366,12 +345,8 @@ impl App { content = content.push( row![ Space::with_width(Length::Fill), - button("Cancel") - .on_press(Message::DismissPanel) - .style(button::secondary), - button("Save") - .on_press(Message::SavePreferences) - .style(button::primary), + action_button("Cancel").on_press(Message::DismissPanel), + action_button("Save").on_press(Message::SavePreferences), ] .spacing(8), ); @@ -379,7 +354,7 @@ impl App { let panel = container(content) .padding(24) .width(520) - .style(container::rounded_box); + .style(overview_style); opaque( container(panel) .center_x(Length::Fill) @@ -390,6 +365,79 @@ impl App { ) } + fn model_manager(&self) -> Element<'_, Message> { + let busy = matches!(self.model_download, ModelDownload::Active(_)); + let mut artifacts = column![]; + for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() { + if index > 0 { + artifacts = artifacts.push(horizontal_rule(1)); + } + artifacts = artifacts.push(model_artifact_row(artifact, busy)); + } + + let mut content = column![ + text("Model Manager").size(26), + text("Download, verify, or remove locally stored model files.") + .size(14) + .color(muted_text()), + ] + .spacing(8); + if !matches!(self.model_download, ModelDownload::Idle) { + content = content.push( + container(model_download_status(&self.model_download)) + .padding(16) + .style(overview_style), + ); + } + if let Some(error) = &self.error { + content = content.push(text(error).style(iced::widget::text::danger)); + } + content = content.push(Space::with_height(8)).push( + container(scrollable(artifacts).height(Length::Fill)) + .height(Length::Fill) + .style(overview_style), + ); + + let base: Element<'_, Message> = container(content) + .width(Length::Fill) + .height(Length::Fill) + .padding(28) + .into(); + let Some(artifact) = self.pending_model_delete else { + return base; + }; + let confirmation = container( + column![ + text("Delete model file?").size(22), + text(format!( + "Delete {artifact}, including any resumable partial download?" + )) + .size(13), + row![ + Space::with_width(Length::Fill), + action_button("Cancel").on_press(Message::CancelDeleteArtifact), + danger_button("Delete").on_press(Message::ConfirmDeleteArtifact), + ] + .spacing(8), + ] + .spacing(14), + ) + .padding(22) + .width(460) + .style(overview_style); + stack![ + base, + opaque( + container(confirmation) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| container::Style::default() + .background(Color::from_rgba8(0, 0, 0, 0.68))) + ) + ] + .into() + } + fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> { let dialog = container( column![ @@ -402,12 +450,8 @@ impl App { .padding(10), row![ Space::with_width(Length::Fill), - button("Cancel") - .on_press(Message::CancelProject) - .style(button::secondary), - button("Add project") - .on_press(Message::ConfirmProject) - .style(button::primary), + action_button("Cancel").on_press(Message::CancelProject), + action_button("Add project").on_press(Message::ConfirmProject), ] .spacing(8), ] @@ -415,7 +459,7 @@ impl App { ) .padding(22) .width(440) - .style(container::rounded_box); + .style(overview_style); opaque( container(dialog) @@ -441,9 +485,78 @@ impl App { } } +fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> { + let status = match artifact.state { + ManagedArtifactState::Missing => "Not downloaded", + ManagedArtifactState::Partial => "Partial download", + ManagedArtifactState::NeedsVerification => "Downloaded; verification required", + ManagedArtifactState::Ready => "Ready and verified", + }; + let download_label = match artifact.state { + ManagedArtifactState::Ready => "Downloaded", + _ if artifact.stored > 0 => "Resume", + _ => "Download", + }; + let download = if artifact.state == ManagedArtifactState::Ready || busy { + action_button(download_label) + } else { + action_button(download_label).on_press(Message::DownloadArtifact(artifact.id)) + }; + let validate = if artifact.can_validate() && !busy { + action_button("Validate").on_press(Message::ValidateArtifact(artifact.id)) + } else { + action_button("Validate") + }; + let delete = if artifact.stored > 0 && !busy { + danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id)) + } else { + danger_button("Delete") + }; + + container( + row![ + icon(ICON_MODEL, 28), + column![ + text(artifact.id.to_string()).size(16), + text(status).size(13).color(muted_text()), + text(format!( + "{} on disk • {} expected", + format_bytes(artifact.stored), + format_bytes(artifact.expected), + )) + .size(12) + .color(muted_text()), + ] + .spacing(5), + Space::with_width(Length::Fill), + row![download, validate, delete] + .spacing(8) + .align_y(Alignment::Center), + ] + .spacing(16) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .padding([18, 20]) + .into() +} + fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { let progress = &download.progress; let percent = progress.fraction() * 100.0; + let measurement = if progress.verification.is_some() { + format!( + "{} verified of {} ({percent:.1}%)", + format_bytes(progress.completed()), + format_bytes(progress.active_total()), + ) + } else { + format!( + "{} of {} ({percent:.1}%)", + format_bytes(progress.completed()), + format_bytes(progress.active_total()), + ) + }; let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 { format!( "{}/s • about {} remaining", @@ -454,11 +567,9 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { "Calculating time remaining…".to_owned() }; let stop = if download.stopping { - button("Stopping…") + danger_button("Stopping…") } else { - button("Stop") - .on_press(Message::StopModelDownload) - .style(button::danger) + danger_button("Stop").on_press(Message::StopModelDownload) }; container( @@ -467,12 +578,7 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { progress_bar(0.0..=1.0, progress.fraction()) .width(180) .height(7), - text(format!( - "{} of {} ({percent:.1}%)", - format_bytes(progress.downloaded), - format_bytes(progress.total), - )) - .size(12), + text(measurement).size(12), text(transfer).size(12), Space::with_width(Length::Fill), stop, @@ -486,60 +592,71 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { .into() } -fn model_download_status<'a>( - download: &ModelDownload, - selected: &DownloadProgress, -) -> Element<'a, Message> { +fn model_download_status(download: &ModelDownload) -> Element<'_, Message> { let (progress, heading, speed, failed) = match download { - ModelDownload::Idle => ( - selected, - if selected.downloaded > 0 { - "Paused — the next download will resume from this point.".to_owned() - } else { - phase_text(selected.phase) - }, - 0.0, - false, - ), + ModelDownload::Idle => unreachable!("idle operations are not displayed"), ModelDownload::Active(active) => ( &active.progress, if active.stopping { - format!("Stopping {}…", active.model) + format!("Stopping {}…", active.artifact) } else { phase_text(active.progress.phase) }, active.bytes_per_second, false, ), - ModelDownload::Complete(model, progress) => ( + ModelDownload::Complete(artifact, operation, progress) => ( progress, - format!("{model} is downloaded and verified."), + match operation { + ModelOperation::Download => format!("{artifact} is downloaded and verified."), + ModelOperation::Validate => format!("{artifact} passed validation."), + }, 0.0, false, ), - ModelDownload::Failed(model, error, progress) => ( + ModelDownload::Failed(artifact, error, progress) => ( progress, - format!("{model} download failed: {error}"), + format!("{artifact} operation failed: {error}"), 0.0, true, ), }; let percent = progress.fraction() * 100.0; - let mut status = column![ - if failed { - text(heading).size(12).style(iced::widget::text::danger) - } else { - text(heading).size(12) - }, - progress_bar(0.0..=1.0, progress.fraction()).height(8), - text(format!( - "{} of {} ({percent:.1}%) • {} remaining", - format_bytes(progress.downloaded), - format_bytes(progress.total), + let measurement = if progress.verification.is_some() { + format!( + "{} verified of {} ({percent:.1}%) • {} remaining", + format_bytes(progress.completed()), + format_bytes(progress.active_total()), format_bytes(progress.remaining()), - )) - .size(12), + ) + } else { + format!( + "{} of {} ({percent:.1}%) • {} remaining", + format_bytes(progress.completed()), + format_bytes(progress.active_total()), + format_bytes(progress.remaining()), + ) + }; + let heading = if failed { + text(heading).size(12).style(iced::widget::text::danger) + } else { + text(heading).size(12) + }; + let mut heading_row = row![heading, Space::with_width(Length::Fill)] + .align_y(Alignment::Center) + .spacing(8); + if let ModelDownload::Active(active) = download { + heading_row = heading_row.push(if active.stopping { + danger_button("Stopping…") + } else { + danger_button("Stop").on_press(Message::StopModelDownload) + }); + } + let mut status = column![ + heading_row, + progress_bar(0.0..=1.0, progress.fraction()).height(8), + text(measurement).size(12), ] .spacing(6); if speed > 0.0 && progress.remaining() > 0 { @@ -606,6 +723,58 @@ pub(crate) fn app_theme() -> Theme { }) } +fn action_button<'a>(content: impl Into>) -> Button<'a, Message> { + button(content).padding([8, 14]).style(action_button_style) +} + +fn danger_button<'a>(content: impl Into>) -> Button<'a, Message> { + button(content).padding([8, 14]).style(danger_button_style) +} + +fn action_button_style(_: &Theme, status: button::Status) -> button::Style { + let (background, text_color) = match status { + button::Status::Active | button::Status::Pressed => { + (Color::from_rgb8(45, 45, 47), Color::WHITE) + } + button::Status::Hovered => (Color::from_rgb8(56, 56, 59), Color::WHITE), + button::Status::Disabled => (Color::from_rgb8(35, 35, 37), muted_text().scale_alpha(0.55)), + }; + button::Style { + background: Some(Background::Color(background)), + text_color, + border: Border { + radius: 12.0.into(), + ..Border::default() + }, + ..button::Style::default() + } +} + +fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style { + let mut style = action_button_style(theme, status); + style.text_color = match status { + button::Status::Disabled => theme.palette().danger.scale_alpha(0.45), + _ => theme.palette().danger, + }; + style +} + +fn overview_style(_: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color::from_rgb8(31, 31, 33))), + border: Border { + color: Color::from_rgb8(61, 61, 64), + width: 1.0, + radius: 14.0.into(), + }, + ..container::Style::default() + } +} + +fn muted_text() -> Color { + Color::from_rgb8(174, 174, 178) +} + fn sidebar_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(23, 23, 25)) } @@ -631,6 +800,15 @@ mod tests { assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50)); } + #[test] + fn action_buttons_share_geometry() { + let theme = app_theme(); + let action = action_button_style(&theme, button::Status::Active); + let danger = danger_button_style(&theme, button::Status::Active); + assert_eq!(action.background, danger.background); + assert_eq!(action.border, danger.border); + } + #[test] fn download_measurements_are_readable() { assert_eq!(format_bytes(1_500_000_000), "1.5 GB"); diff --git a/src/main.rs b/src/main.rs index 91619bd..c12acf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,24 @@ mod app; mod database; mod model; +#[cfg(target_os = "macos")] +mod native_menu; mod schema; -use app::{App, app_theme}; -use iced::{Size, Task}; +use app::{App, Message, app_icon, app_theme}; +use iced::{Size, window}; fn main() -> iced::Result { - iced::application("DS4Server", App::update, App::view) + iced::daemon(App::title, App::update, App::view) .subscription(App::subscription) - .theme(|_| app_theme()) - .window(iced::window::Settings { - size: Size::new(1120.0, 720.0), - min_size: Some(Size::new(760.0, 480.0)), - ..Default::default() + .theme(|_, _| app_theme()) + .run_with(|| { + let (main_window, open) = window::open(window::Settings { + size: Size::new(1120.0, 720.0), + min_size: Some(Size::new(760.0, 480.0)), + icon: Some(app_icon()), + ..Default::default() + }); + (App::load(main_window), open.map(Message::WindowOpened)) }) - .run_with(|| (App::load(), Task::none())) } diff --git a/src/model.rs b/src/model.rs index 85491e7..0c27386 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,7 +2,7 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use sha2::{Digest, Sha256}; @@ -11,6 +11,12 @@ pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [ ModelChoice::DeepSeekV4Pro, ModelChoice::Glm52, ]; +pub(crate) const MANAGED_ARTIFACTS: [ManagedArtifactId; 4] = [ + ManagedArtifactId::DeepSeekV4Flash, + ManagedArtifactId::DeepSeekV4FlashDspark, + ManagedArtifactId::DeepSeekV4Pro, + ManagedArtifactId::Glm52, +]; const DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf"; const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf"; @@ -69,6 +75,7 @@ impl ModelChoice { self == Self::DeepSeekV4Flash } + #[cfg(test)] fn main_artifact(self) -> &'static Artifact { match self { Self::DeepSeekV4Flash => &FLASH, @@ -77,6 +84,7 @@ impl ModelChoice { } } + #[cfg(test)] fn artifacts(self, dspark_enabled: bool) -> impl Iterator { [ Some(self.main_artifact()), @@ -85,11 +93,63 @@ impl ModelChoice { .into_iter() .flatten() } +} - pub(crate) fn download_size(self, dspark_enabled: bool) -> u64 { - self.artifacts(dspark_enabled) - .map(|artifact| artifact.size) - .sum() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ManagedArtifactId { + DeepSeekV4Flash, + DeepSeekV4FlashDspark, + DeepSeekV4Pro, + Glm52, +} + +impl ManagedArtifactId { + pub(crate) fn model(self) -> ModelChoice { + match self { + Self::DeepSeekV4Flash | Self::DeepSeekV4FlashDspark => ModelChoice::DeepSeekV4Flash, + Self::DeepSeekV4Pro => ModelChoice::DeepSeekV4Pro, + Self::Glm52 => ModelChoice::Glm52, + } + } + + fn artifact(self) -> &'static Artifact { + match self { + Self::DeepSeekV4Flash => &FLASH, + Self::DeepSeekV4FlashDspark => &FLASH_DSPARK, + Self::DeepSeekV4Pro => &PRO, + Self::Glm52 => &GLM, + } + } +} + +impl fmt::Display for ManagedArtifactId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.artifact().label) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ManagedArtifactState { + Missing, + Partial, + NeedsVerification, + Ready, +} + +#[derive(Clone, Debug)] +pub(crate) struct ManagedArtifact { + pub(crate) id: ManagedArtifactId, + pub(crate) stored: u64, + pub(crate) expected: u64, + pub(crate) state: ManagedArtifactState, +} + +impl ManagedArtifact { + pub(crate) fn can_validate(&self) -> bool { + matches!( + self.state, + ManagedArtifactState::NeedsVerification | ManagedArtifactState::Ready + ) } } @@ -106,20 +166,38 @@ pub(crate) struct DownloadProgress { pub(crate) downloaded: u64, pub(crate) total: u64, pub(crate) phase: DownloadPhase, + pub(crate) verification: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct VerificationProgress { + pub(crate) verified: u64, + pub(crate) total: u64, } impl DownloadProgress { pub(crate) fn remaining(&self) -> u64 { - self.total.saturating_sub(self.downloaded) + self.active_total().saturating_sub(self.completed()) } pub(crate) fn fraction(&self) -> f32 { - if self.total == 0 { + let total = self.active_total(); + if total == 0 { 0.0 } else { - self.downloaded as f32 / self.total as f32 + self.completed() as f32 / total as f32 } } + + pub(crate) fn completed(&self) -> u64 { + self.verification + .map_or(self.downloaded, |progress| progress.verified) + } + + pub(crate) fn active_total(&self) -> u64 { + self.verification + .map_or(self.total, |progress| progress.total) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -183,58 +261,184 @@ impl Artifact { .or_else(|_| self.partial_path(model, models_path).metadata()) .map_or(0, |metadata| metadata.len().min(self.size)) } + + fn stored_bytes(&self, model: ModelChoice, models_path: &Path) -> u64 { + [ + &self.path(model, models_path), + &self.partial_path(model, models_path), + ] + .into_iter() + .filter_map(|path| path.metadata().ok()) + .map(|metadata| metadata.len()) + .sum() + } } -pub(crate) fn download_progress( - model: ModelChoice, - dspark_enabled: bool, +pub(crate) fn managed_artifacts(models_path: &Path) -> Vec { + MANAGED_ARTIFACTS + .into_iter() + .map(|id| { + let model = id.model(); + let artifact = id.artifact(); + let stored = artifact.stored_bytes(model, models_path); + let complete_file = artifact + .path(model, models_path) + .metadata() + .or_else(|_| artifact.partial_path(model, models_path).metadata()) + .is_ok_and(|metadata| metadata.len() == artifact.size); + let state = if artifact.is_installed(model, models_path) { + ManagedArtifactState::Ready + } else if complete_file { + ManagedArtifactState::NeedsVerification + } else if stored > 0 { + ManagedArtifactState::Partial + } else { + ManagedArtifactState::Missing + }; + ManagedArtifact { + id, + stored, + expected: artifact.size, + state, + } + }) + .collect() +} + +pub(crate) fn artifact_download_progress( + id: ManagedArtifactId, models_path: &Path, ) -> DownloadProgress { - let mut downloaded = 0; - let mut phase = DownloadPhase::Complete; - for artifact in model.artifacts(dspark_enabled) { - let bytes = artifact.downloaded_bytes(model, models_path); - downloaded += bytes; - if phase == DownloadPhase::Complete && !artifact.is_installed(model, models_path) { - phase = if bytes >= artifact.size { - DownloadPhase::Verifying(artifact.label) - } else if bytes > 0 { - DownloadPhase::Downloading(artifact.label) - } else { - DownloadPhase::Pending(artifact.label) - }; - } - } + let model = id.model(); + let artifact = id.artifact(); + let downloaded = artifact.downloaded_bytes(model, models_path); + let installed = artifact.is_installed(model, models_path); + let verification = + (!installed && downloaded >= artifact.size).then_some(VerificationProgress { + verified: 0, + total: artifact.size, + }); + let phase = if installed { + DownloadPhase::Complete + } else if verification.is_some() { + DownloadPhase::Verifying(artifact.label) + } else if downloaded > 0 { + DownloadPhase::Downloading(artifact.label) + } else { + DownloadPhase::Pending(artifact.label) + }; DownloadProgress { downloaded, - total: model.download_size(dspark_enabled), + total: artifact.size, phase, + verification, } } -pub(crate) fn download( - model: ModelChoice, - dspark_enabled: bool, +pub(crate) fn artifact_verification_progress( + id: ManagedArtifactId, + verified: u64, +) -> DownloadProgress { + let artifact = id.artifact(); + DownloadProgress { + downloaded: artifact.size, + total: artifact.size, + phase: DownloadPhase::Verifying(artifact.label), + verification: Some(VerificationProgress { + verified: verified.min(artifact.size), + total: artifact.size, + }), + } +} + +pub(crate) fn download_managed_artifact( + id: ManagedArtifactId, models_path: &Path, cancel: &AtomicBool, + verified_bytes: &AtomicU64, ) -> Result { - for artifact in model.artifacts(dspark_enabled) { - if download_artifact_with_cancel(model, artifact, models_path, cancel)? - == DownloadOutcome::Stopped - { - return Ok(DownloadOutcome::Stopped); + download_artifact_with_cancel( + id.model(), + id.artifact(), + models_path, + cancel, + verified_bytes, + ) +} + +pub(crate) fn validate_managed_artifact( + id: ManagedArtifactId, + models_path: &Path, + cancel: &AtomicBool, + verified_bytes: &AtomicU64, +) -> Result { + let model = id.model(); + let artifact = id.artifact(); + let destination = artifact.path(model, models_path); + let partial = artifact.partial_path(model, models_path); + let (path, promote) = if destination.exists() { + (destination.clone(), false) + } else if partial.exists() { + (partial.clone(), true) + } else { + return Err(format!("{} is not downloaded", artifact.label)); + }; + + match verify(&path, artifact, cancel, verified_bytes) { + Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped), + Ok(DownloadOutcome::Complete) => {} + Err(error) => { + let marker = artifact.verification_path(model, models_path); + if let Err(remove_error) = fs::remove_file(marker) + && remove_error.kind() != std::io::ErrorKind::NotFound + { + return Err(format!( + "{error}; could not remove checksum marker: {remove_error}" + )); + } + return Err(error); } } + if promote { + fs::rename(partial, destination).map_err(|error| error.to_string())?; + } + mark_verified(model, artifact, models_path)?; Ok(DownloadOutcome::Complete) } +pub(crate) fn delete_managed_artifact( + id: ManagedArtifactId, + models_path: &Path, +) -> Result<(), String> { + let model = id.model(); + let artifact = id.artifact(); + for path in [ + artifact.path(model, models_path), + artifact.partial_path(model, models_path), + artifact.verification_path(model, models_path), + ] { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + Ok(()) +} + #[cfg(test)] fn download_artifact( model: ModelChoice, artifact: &Artifact, models_path: &Path, ) -> Result { - download_artifact_with_cancel(model, artifact, models_path, &AtomicBool::new(false)) + download_artifact_with_cancel( + model, + artifact, + models_path, + &AtomicBool::new(false), + &AtomicU64::new(0), + ) } fn download_artifact_with_cancel( @@ -242,6 +446,7 @@ fn download_artifact_with_cancel( artifact: &Artifact, models_path: &Path, cancel: &AtomicBool, + verified_bytes: &AtomicU64, ) -> Result { if cancel.load(Ordering::Relaxed) { return Ok(DownloadOutcome::Stopped); @@ -251,7 +456,7 @@ fn download_artifact_with_cancel( return Ok(DownloadOutcome::Complete); } if destination.exists() { - if verify(&destination, artifact, cancel)? == DownloadOutcome::Stopped { + if verify(&destination, artifact, cancel, verified_bytes)? == DownloadOutcome::Stopped { return Ok(DownloadOutcome::Stopped); } mark_verified(model, artifact, models_path)?; @@ -276,7 +481,7 @@ fn download_artifact_with_cancel( if cancel.load(Ordering::Relaxed) { return Ok(DownloadOutcome::Stopped); } - match verify(&partial, artifact, cancel) { + match verify(&partial, artifact, cancel, verified_bytes) { Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped), Ok(DownloadOutcome::Complete) => {} Err(error) => { @@ -307,7 +512,9 @@ fn verify( path: &Path, artifact: &Artifact, cancel: &AtomicBool, + verified_bytes: &AtomicU64, ) -> Result { + verified_bytes.store(0, Ordering::Relaxed); let size = path.metadata().map_err(|error| error.to_string())?.len(); if size != artifact.size { return Err(format!( @@ -329,6 +536,7 @@ fn verify( break; } hasher.update(&buffer[..count]); + verified_bytes.fetch_add(count as u64, Ordering::Relaxed); } let actual = hex(&hasher.finalize()); if actual != artifact.sha256 { @@ -476,6 +684,59 @@ mod tests { fs::remove_dir_all(models_path).unwrap(); } + #[test] + fn verification_reports_bytes_read() { + let path = std::env::temp_dir().join(format!( + "ds4-server-verify-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&path, b"abc").unwrap(); + let artifact = Artifact { + label: "test model", + file_name: "unused", + repository: "unused", + size: 3, + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }; + let verified_bytes = AtomicU64::new(999); + + assert_eq!( + verify(&path, &artifact, &AtomicBool::new(false), &verified_bytes,).unwrap(), + DownloadOutcome::Complete + ); + assert_eq!(verified_bytes.load(Ordering::Relaxed), 3); + fs::remove_file(path).unwrap(); + } + + #[test] + fn managed_artifact_inventory_and_delete_include_partial_files() { + let models_path = std::env::temp_dir().join(format!( + "ds4-server-manager-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let id = ManagedArtifactId::DeepSeekV4Flash; + let partial = id.artifact().partial_path(id.model(), &models_path); + fs::create_dir_all(partial.parent().unwrap()).unwrap(); + fs::write(&partial, b"part").unwrap(); + + let managed = managed_artifacts(&models_path) + .into_iter() + .find(|artifact| artifact.id == id) + .unwrap(); + assert_eq!(managed.stored, 4); + assert_eq!(managed.state, ManagedArtifactState::Partial); + + delete_managed_artifact(id, &models_path).unwrap(); + assert!(!partial.exists()); + fs::remove_dir_all(models_path).unwrap(); + } + #[test] fn restart_resumes_at_the_existing_partial_byte() { let content = b"restart-resume works"; @@ -557,6 +818,7 @@ mod tests { &artifact, &directory, &cancel, + &AtomicU64::new(0), ) .unwrap(), DownloadOutcome::Stopped diff --git a/src/native_menu.rs b/src/native_menu.rs new file mode 100644 index 0000000..866a5be --- /dev/null +++ b/src/native_menu.rs @@ -0,0 +1,90 @@ +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; + +const PREFERENCES: &str = "preferences"; +const MODEL_MANAGER: &str = "model-manager"; + +pub(crate) struct NativeMenu { + _menu: Menu, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum NativeMenuEvent { + Preferences, + ModelManager, +} + +pub(crate) fn install() -> Result { + let menu = Menu::new(); + let application = Submenu::new("DS4Server", true); + let edit = Submenu::new("Edit", true); + let window = Submenu::new("Window", true); + let preferences = MenuItem::with_id( + PREFERENCES, + "Preferences…", + true, + Some(Accelerator::new(Some(Modifiers::SUPER), Code::Comma)), + ); + let model_manager = MenuItem::with_id( + MODEL_MANAGER, + "Model Manager…", + true, + Some(Accelerator::new( + Some(Modifiers::SUPER | Modifiers::SHIFT), + Code::KeyM, + )), + ); + + application + .append_items(&[ + &PredefinedMenuItem::about(None, None), + &PredefinedMenuItem::separator(), + &preferences, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(None), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::quit(None), + ]) + .map_err(|error| error.to_string())?; + edit.append_items(&[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + ]) + .map_err(|error| error.to_string())?; + window + .append_items(&[ + &model_manager, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(None), + &PredefinedMenuItem::close_window(None), + &PredefinedMenuItem::bring_all_to_front(None), + ]) + .map_err(|error| error.to_string())?; + menu.append_items(&[&application, &edit, &window]) + .map_err(|error| error.to_string())?; + menu.init_for_nsapp(); + window.set_as_windows_menu_for_nsapp(); + Ok(NativeMenu { _menu: menu }) +} + +pub(crate) fn next_event() -> Option { + while let Ok(event) = MenuEvent::receiver().try_recv() { + if event.id == PREFERENCES { + return Some(NativeMenuEvent::Preferences); + } + if event.id == MODEL_MANAGER { + return Some(NativeMenuEvent::ModelManager); + } + } + None +}