Add model management and macOS integration

This commit is contained in:
Georg Bauer
2026-07-24 15:18:35 +02:00
parent ec77ab94fb
commit ad7167e84a
11 changed files with 1486 additions and 251 deletions

View File

@@ -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:

533
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

37
PLAN.md
View File

@@ -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/<model-id>/` 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

View File

@@ -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

Binary file not shown.

View File

@@ -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<window::Id>,
pub(super) pending_model_delete: Option<ManagedArtifactId>,
#[cfg(target_os = "macos")]
_native_menu: Option<crate::native_menu::NativeMenu>,
database: Option<Database>,
projects: Vec<ProjectWithSessions>,
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<AtomicU64>,
cancel: Arc<AtomicBool>,
result: mpsc::Receiver<Result<DownloadOutcome, String>>,
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<Message> {
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<Message> {
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<Message> {
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<Item = ()> {
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<Message> {
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());

View File

@@ -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<Element<'a, Message>>) -> Button<'a, Message> {
button(content).padding([8, 14]).style(action_button_style)
}
fn danger_button<'a>(content: impl Into<Element<'a, Message>>) -> 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");

View File

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

View File

@@ -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<Item = &'static Artifact> {
[
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<VerificationProgress>,
}
#[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<ManagedArtifact> {
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<DownloadOutcome, String> {
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<DownloadOutcome, String> {
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<DownloadOutcome, String> {
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<DownloadOutcome, String> {
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<DownloadOutcome, String> {
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

90
src/native_menu.rs Normal file
View File

@@ -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<NativeMenu, String> {
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<NativeMenuEvent> {
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
}