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. - Keep changes focused, handle errors explicitly, and add the smallest useful test for non-trivial behavior.
- Preserve `rustfmt` output and keep Clippy warning-free. - 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 ## Commit gates
Run before every commit: Run before every commit:

533
Cargo.lock generated
View File

@@ -299,6 +299,29 @@ dependencies = [
"syn 3.0.3", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -343,6 +366,9 @@ name = "bitflags"
version = "2.13.1" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block" name = "block"
@@ -437,6 +463,31 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" 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]] [[package]]
name = "calloop" name = "calloop"
version = "0.13.0" version = "0.13.0"
@@ -500,6 +551,16 @@ dependencies = [
"shlex", "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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -719,6 +780,15 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@@ -1066,6 +1136,8 @@ dependencies = [
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"iced", "iced",
"muda",
"png",
"rfd", "rfd",
"sha2", "sha2",
"ureq", "ureq",
@@ -1079,7 +1151,7 @@ checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
dependencies = [ dependencies = [
"darling", "darling",
"either", "either",
"heck", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -1207,6 +1279,16 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1424,6 +1506,64 @@ dependencies = [
"slab", "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]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -1488,6 +1628,38 @@ dependencies = [
"weezl", "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]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -1505,6 +1677,53 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" 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]] [[package]]
name = "glow" name = "glow"
version = "0.13.1" version = "0.13.1"
@@ -1526,6 +1745,17 @@ dependencies = [
"gl_generator", "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]] [[package]]
name = "gpu-alloc" name = "gpu-alloc"
version = "0.6.2" version = "0.6.2"
@@ -1578,6 +1808,58 @@ dependencies = [
"bitflags 2.13.1", "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]] [[package]]
name = "guillotiere" name = "guillotiere"
version = "0.6.2" version = "0.6.2"
@@ -1648,6 +1930,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -1741,6 +2029,7 @@ dependencies = [
"iced_core", "iced_core",
"log", "log",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"tokio",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-timer", "wasm-timer",
] ]
@@ -2102,6 +2391,17 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -2194,6 +2494,25 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -2294,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d"
dependencies = [ dependencies = [
"serde", "serde",
"toml", "toml 0.9.12+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -2318,6 +2637,26 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "naga" name = "naga"
version = "0.19.2" version = "0.19.2"
@@ -2421,7 +2760,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -2797,6 +3136,31 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -3029,13 +3393,57 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" 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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ 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]] [[package]]
@@ -3524,6 +3932,15 @@ dependencies = [
"syn 3.0.3", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -3877,6 +4294,25 @@ dependencies = [
"libc", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -4033,6 +4469,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 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]] [[package]]
name = "toml" name = "toml"
version = "0.9.12+spec-1.1.0" version = "0.9.12+spec-1.1.0"
@@ -4040,12 +4497,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_spanned", "serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 0.7.15", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.7.5+spec-1.1.0" version = "0.7.5+spec-1.1.0"
@@ -4064,6 +4530,30 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.13+spec-1.1.0" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -4924,6 +5420,15 @@ dependencies = [
"xkbcommon-dl", "xkbcommon-dl",
] ]
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "0.7.15"
@@ -4960,6 +5465,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"
@@ -5147,7 +5662,7 @@ version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -5160,7 +5675,7 @@ version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -5311,7 +5826,7 @@ version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -5324,7 +5839,7 @@ version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",

View File

@@ -10,11 +10,15 @@ publish = false
[dependencies] [dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = "2.3.2" 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" rfd = "0.15.4"
sha2 = "0.11.0" sha2 = "0.11.0"
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.15.3"
[package.metadata.packager] [package.metadata.packager]
product-name = "DS4Server" product-name = "DS4Server"
identifier = "DS4Server.rfc1437.de" identifier = "DS4Server.rfc1437.de"

37
PLAN.md
View File

@@ -12,10 +12,10 @@ sessions, tools, and turn orchestration.
| Area | Status | Available now | Still open | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization |
The application currently manages durable project/session metadata and model The application currently manages durable project/session metadata and model
preferences, and can download the selected model's main and optional DSpark preferences. Its separate Model Manager can download, validate, and delete
GGUF into managed Application Support storage without blocking the UI. It does main and DSpark GGUF artifacts in managed Application Support storage without
not yet load a model, serve HTTP, save chat messages, or run an agent. 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 ## Phase 0 — project and session shell
@@ -204,17 +205,23 @@ chat or HTTP code re-parsing preference fields.
### Targeted downloads and storage ### Targeted downloads and storage
- The Preferences panel can download the selected model's main GGUF and the - Preferences only selects the model/runtime configuration. A separate Model
DSpark support GGUF when enabled. A dedicated worker thread performs HTTP and Manager window, reachable from the native Window menu, lists every managed
verification work so Preferences and the rest of the application stay main and DSpark artifact with its on-disk size and state, and provides
interactive. 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 - 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 downloader. Stream response chunks directly into a `.part` file and use a
Rust SHA-256 implementation for verification. Rust SHA-256 implementation for verification.
- Report aggregate bytes present, total bytes, bytes missing, percentage, - Report aggregate bytes present, total bytes, bytes missing, percentage,
smoothed transfer rate, current artifact/verification phase, and estimated 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. 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` - 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 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 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 `~/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 mmap/load them from there. Do not place managed models in the app bundle or a
project directory. project directory.
- Download only the selected model's main artifact. Download its DSpark - Download only the artifact explicitly requested in Model Manager; never
artifact only when DSpark is enabled; never prefetch other catalog models or prefetch other catalog models or optional artifacts.
optional artifacts.
- Remove checksum-invalid partial files without touching a valid model. Keep - Remove checksum-invalid partial files without touching a valid model. Keep
partial files after cancellation, application exit, network failure, or an partial files after cancellation, application exit, network failure, or an
interrupted verification so they remain restart-resumable. interrupted verification so they remain restart-resumable.
- When the selected model changes or DSpark is disabled, offer removal of the - Delete model artifacts only through an explicit, confirmed Model Manager
now-unused artifact; do not delete large existing downloads without explicit action; changing Preferences must never remove files.
confirmation.
Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without 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 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 macOS folder picker selects each workspace, then the app asks for its display
name. Projects, sessions, and model preferences are persisted through Diesel in name. Projects, sessions, and model preferences are persisted through Diesel in
SQLite. Open Preferences with `Command-,` to select the model, toggle DSpark, SQLite. Open Preferences with `Command-,` to select the model, toggle DSpark,
configure the model idle timeout, and download the selected main GGUF with and configure the model idle timeout. The separate Model Manager (`Shift-Command-M`)
its optional DSpark support. Rust-native background downloads show live byte lists local main and DSpark artifacts, their on-disk sizes and state, and lets
progress, speed, and ETA in Preferences and the app status bar. Stopping or you download, resume, validate, or delete them. Rust-native background work
quitting keeps the partial file; the next Download/Resume action continues from shows live byte progress, speed, and ETA in Model Manager and the app status
that exact byte after relaunch. Exact size and SHA-256 verification happen bar. Stopping or quitting keeps the partial file; the next Download/Resume
before an artifact becomes usable. action continues from that exact byte after relaunch. Exact size and SHA-256
verification happen before an artifact becomes usable.
```sh ```sh
cargo install cargo-packager --locked --version 0.11.8 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; pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions}; use crate::database::{AppPreferences, Database, ProjectWithSessions};
use crate::model::{self, DownloadOutcome, DownloadProgress, ModelChoice}; use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
use iced::futures::{SinkExt, Stream}; use iced::{Size, Subscription, Task, keyboard, window};
use iced::{Subscription, Task, keyboard};
use rfd::AsyncFileDialog; use rfd::AsyncFileDialog;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; 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::sync::mpsc::{self, TryRecvError};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -37,6 +36,11 @@ impl PreferenceDraft {
} }
pub(crate) struct App { 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>, database: Option<Database>,
projects: Vec<ProjectWithSessions>, projects: Vec<ProjectWithSessions>,
preferences: AppPreferences, preferences: AppPreferences,
@@ -56,18 +60,25 @@ pub(crate) struct App {
pub(super) enum ModelDownload { pub(super) enum ModelDownload {
Idle, Idle,
Active(ActiveDownload), Active(ActiveDownload),
Complete(ModelChoice, DownloadProgress), Complete(ManagedArtifactId, ModelOperation, DownloadProgress),
Failed(ModelChoice, String, DownloadProgress), Failed(ManagedArtifactId, String, DownloadProgress),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ModelOperation {
Download,
Validate,
} }
#[derive(Debug)] #[derive(Debug)]
pub(super) struct ActiveDownload { pub(super) struct ActiveDownload {
model: ModelChoice, artifact: ManagedArtifactId,
dspark_enabled: bool, operation: ModelOperation,
progress: DownloadProgress, progress: DownloadProgress,
sampled_at: Instant, sampled_at: Instant,
sampled_bytes: u64, sampled_bytes: u64,
bytes_per_second: f64, bytes_per_second: f64,
verified_bytes: Arc<AtomicU64>,
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
result: mpsc::Receiver<Result<DownloadOutcome, String>>, result: mpsc::Receiver<Result<DownloadOutcome, String>>,
stopping: bool, stopping: bool,
@@ -75,13 +86,22 @@ pub(super) struct ActiveDownload {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) enum Message { pub(crate) enum Message {
Noop,
OpenPreferences, OpenPreferences,
OpenModelManager,
ModelManagerOpened(window::Id),
WindowOpened(window::Id),
WindowClosed(window::Id),
DismissPanel, DismissPanel,
PreferenceModelChanged(ModelChoice), PreferenceModelChanged(ModelChoice),
PreferenceDsparkChanged(bool), PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String), PreferenceTimeoutChanged(String),
SavePreferences, SavePreferences,
DownloadSelectedModel, DownloadArtifact(ManagedArtifactId),
ValidateArtifact(ManagedArtifactId),
DeleteArtifact(ManagedArtifactId),
ConfirmDeleteArtifact,
CancelDeleteArtifact,
StopModelDownload, StopModelDownload,
DownloadProgressTick, DownloadProgressTick,
ChooseProjectFolder, ChooseProjectFolder,
@@ -97,16 +117,21 @@ pub(crate) enum Message {
} }
impl App { impl App {
pub(crate) fn load() -> Self { pub(crate) fn load(main_window: window::Id) -> Self {
let path = application_support_path().join("data.sqlite3"); let path = application_support_path().join("data.sqlite3");
match Database::open(&path) { match Database::open(&path) {
Ok(mut database) => match (database.load_projects(), database.load_preferences()) { Ok(mut database) => match (database.load_projects(), database.load_preferences()) {
(Ok(projects), Ok(preferences)) => { (Ok(projects), Ok(preferences)) => {
let preference_draft = match PreferenceDraft::from_saved(&preferences) { let preference_draft = match PreferenceDraft::from_saved(&preferences) {
Ok(draft) => draft, Ok(draft) => draft,
Err(error) => return Self::failed(error), Err(error) => return Self::failed(error, main_window),
}; };
Self { Self {
main_window,
model_manager_window: None,
pending_model_delete: None,
#[cfg(target_os = "macos")]
_native_menu: None,
database: Some(database), database: Some(database),
projects, projects,
preferences, preferences,
@@ -122,17 +147,22 @@ impl App {
error: None, 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 preferences = AppPreferences::default();
let preference_draft = PreferenceDraft::from_saved(&preferences) let preference_draft = PreferenceDraft::from_saved(&preferences)
.expect("default preferences must use a supported model"); .expect("default preferences must use a supported model");
Self { Self {
main_window,
model_manager_window: None,
pending_model_delete: None,
#[cfg(target_os = "macos")]
_native_menu: None,
database: None, database: None,
projects: Vec::new(), projects: Vec::new(),
preferences, preferences,
@@ -151,7 +181,36 @@ impl App {
pub(crate) fn update(&mut self, message: Message) -> Task<Message> { pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::Noop => {}
Message::OpenPreferences => self.open_preferences(), 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 => { Message::DismissPanel => {
if self.preferences_open { if self.preferences_open {
self.preferences_open = false; self.preferences_open = false;
@@ -179,41 +238,27 @@ impl App {
self.preference_error = None; self.preference_error = None;
} }
Message::SavePreferences => self.save_preferences(), Message::SavePreferences => self.save_preferences(),
Message::DownloadSelectedModel => { Message::DownloadArtifact(artifact) => {
if matches!(self.model_download, ModelDownload::Active(_)) { self.start_model_operation(artifact, ModelOperation::Download)
return Task::none();
} }
let model = self.preference_draft.model; Message::ValidateArtifact(artifact) => {
let dspark_enabled = self.start_model_operation(artifact, ModelOperation::Validate)
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 { Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact),
model, Message::ConfirmDeleteArtifact => {
dspark_enabled, if let Some(artifact) = self.pending_model_delete.take() {
sampled_at: Instant::now(), match model::delete_managed_artifact(artifact, &models_path()) {
sampled_bytes: progress.downloaded, Ok(()) => {
progress, self.model_download = ModelDownload::Idle;
bytes_per_second: 0.0, self.error = None;
cancel: Arc::clone(&cancel),
result: result_receiver,
stopping: false,
});
} }
Err(error) => {
self.error = Some(format!("Could not delete {artifact}: {error}"))
}
}
}
}
Message::CancelDeleteArtifact => self.pending_model_delete = None,
Message::StopModelDownload => { Message::StopModelDownload => {
if let ModelDownload::Active(download) = &mut self.model_download { if let ModelDownload::Active(download) = &mut self.model_download {
download.stopping = true; download.stopping = true;
@@ -290,15 +335,105 @@ impl App {
} }
pub(crate) fn subscription(&self) -> Subscription<Message> { pub(crate) fn subscription(&self) -> Subscription<Message> {
let shortcuts = keyboard::on_key_press(shortcut); let mut subscriptions = vec![
if matches!(self.model_download, ModelDownload::Active(_)) { keyboard::on_key_press(shortcut),
Subscription::batch([ window::close_requests().map(Message::WindowClosed),
shortcuts, window::close_events().map(Message::WindowClosed),
Subscription::run(download_ticks).map(|_| Message::DownloadProgressTick), ];
]) #[cfg(target_os = "macos")]
} else { subscriptions.push(iced::time::every(Duration::from_millis(50)).map(|_| {
shortcuts 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(_)) {
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) { fn open_preferences(&mut self) {
@@ -443,11 +578,27 @@ impl App {
return; return;
}; };
let now = Instant::now(); let now = Instant::now();
let progress = let verified = download.verified_bytes.load(Ordering::Relaxed);
model::download_progress(download.model, download.dspark_enabled, &models_path()); 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 elapsed = now.duration_since(download.sampled_at).as_secs_f64();
let transferred = progress.downloaded.saturating_sub(download.sampled_bytes); let completed = progress.completed();
if transferred > 0 && elapsed > 0.0 { 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; let current = transferred as f64 / elapsed;
download.bytes_per_second = if download.bytes_per_second == 0.0 { download.bytes_per_second = if download.bytes_per_second == 0.0 {
current current
@@ -457,7 +608,7 @@ impl App {
} }
download.progress = progress; download.progress = progress;
download.sampled_at = now; download.sampled_at = now;
download.sampled_bytes = download.progress.downloaded; download.sampled_bytes = completed;
let result = match download.result.try_recv() { let result = match download.result.try_recv() {
Ok(result) => Some(result), Ok(result) => Some(result),
@@ -466,12 +617,14 @@ impl App {
Some(Err("Download worker stopped unexpectedly.".into())) Some(Err("Download worker stopped unexpectedly.".into()))
} }
}; };
let model = download.model; let artifact = download.artifact;
let operation = download.operation;
let progress = download.progress.clone(); let progress = download.progress.clone();
if let Some(result) = result { if let Some(result) = result {
match result { match result {
Ok(DownloadOutcome::Complete) => { 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; self.error = None;
} }
Ok(DownloadOutcome::Stopped) => { Ok(DownloadOutcome::Stopped) => {
@@ -480,7 +633,7 @@ impl App {
} }
Err(error) => { Err(error) => {
self.error = Some(error.clone()); 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> { fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
match key.as_ref() { match key.as_ref() {
keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences), 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), keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
_ => None, _ => None,
} }
@@ -527,6 +672,24 @@ fn models_path() -> PathBuf {
application_support_path().join("models") 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -538,6 +701,11 @@ mod tests {
keyboard::Modifiers::COMMAND, keyboard::Modifiers::COMMAND,
); );
assert!(matches!(message, Some(Message::OpenPreferences))); 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::DeepSeekV4Flash.supports_dspark());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark()); assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
assert!(!ModelChoice::Glm52.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::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::theme::{Palette, palette};
use iced::widget::{ use iced::widget::{
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row, Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
scrollable, stack, svg, text, text_input, 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; use std::path::Path;
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg"); 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"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
impl App { 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![ let mut shell = column![
row![self.sidebar(), self.detail()] row![self.sidebar(), self.detail()]
.width(Length::Fill) .width(Length::Fill)
@@ -178,11 +188,9 @@ impl App {
.spacing(8) .spacing(8)
.align_y(Alignment::Center); .align_y(Alignment::Center);
let open_project = if self.database.is_some() && !self.choosing_folder { let open_project = if self.database.is_some() && !self.choosing_folder {
button(open_project_content) action_button(open_project_content).on_press(Message::ChooseProjectFolder)
.on_press(Message::ChooseProjectFolder)
.style(button::primary)
} else { } else {
button(open_project_content) action_button(open_project_content)
}; };
return container( return container(
column![ column![
@@ -237,7 +245,7 @@ impl App {
.to_string(), .to_string(),
) )
.size(12), .size(12),
button(icon(ICON_SEND, 18)), action_button(icon(ICON_SEND, 18)).padding(8),
] ]
.align_y(Alignment::Center), .align_y(Alignment::Center),
] ]
@@ -245,7 +253,7 @@ impl App {
) )
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.style(container::rounded_box), .style(overview_style),
] ]
.height(Length::Fill) .height(Length::Fill)
.spacing(8); .spacing(8);
@@ -291,33 +299,6 @@ impl App {
self.preference_draft.dspark_enabled, self.preference_draft.dspark_enabled,
) )
.on_toggle_maybe(dspark_toggle); .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![ let mut content = column![
row![ row![
@@ -338,13 +319,11 @@ impl App {
.width(Length::Fill), .width(Length::Fill),
dspark, dspark,
text(if self.preference_draft.model.supports_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 { } else {
"DSpark is not available for the selected model." "DSpark is not available for the selected model."
}) })
.size(12), .size(12),
download.style(button::secondary),
model_download_status(&self.model_download, &selected_progress),
Space::with_height(8), Space::with_height(8),
text("INACTIVITY").size(11), text("INACTIVITY").size(11),
row![ row![
@@ -366,12 +345,8 @@ impl App {
content = content.push( content = content.push(
row![ row![
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button("Cancel") action_button("Cancel").on_press(Message::DismissPanel),
.on_press(Message::DismissPanel) action_button("Save").on_press(Message::SavePreferences),
.style(button::secondary),
button("Save")
.on_press(Message::SavePreferences)
.style(button::primary),
] ]
.spacing(8), .spacing(8),
); );
@@ -379,7 +354,7 @@ impl App {
let panel = container(content) let panel = container(content)
.padding(24) .padding(24)
.width(520) .width(520)
.style(container::rounded_box); .style(overview_style);
opaque( opaque(
container(panel) container(panel)
.center_x(Length::Fill) .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> { fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
let dialog = container( let dialog = container(
column![ column![
@@ -402,12 +450,8 @@ impl App {
.padding(10), .padding(10),
row![ row![
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button("Cancel") action_button("Cancel").on_press(Message::CancelProject),
.on_press(Message::CancelProject) action_button("Add project").on_press(Message::ConfirmProject),
.style(button::secondary),
button("Add project")
.on_press(Message::ConfirmProject)
.style(button::primary),
] ]
.spacing(8), .spacing(8),
] ]
@@ -415,7 +459,7 @@ impl App {
) )
.padding(22) .padding(22)
.width(440) .width(440)
.style(container::rounded_box); .style(overview_style);
opaque( opaque(
container(dialog) 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> { fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress; let progress = &download.progress;
let percent = progress.fraction() * 100.0; 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 { let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
format!( format!(
"{}/s • about {} remaining", "{}/s • about {} remaining",
@@ -454,11 +567,9 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
"Calculating time remaining…".to_owned() "Calculating time remaining…".to_owned()
}; };
let stop = if download.stopping { let stop = if download.stopping {
button("Stopping…") danger_button("Stopping…")
} else { } else {
button("Stop") danger_button("Stop").on_press(Message::StopModelDownload)
.on_press(Message::StopModelDownload)
.style(button::danger)
}; };
container( container(
@@ -467,12 +578,7 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
progress_bar(0.0..=1.0, progress.fraction()) progress_bar(0.0..=1.0, progress.fraction())
.width(180) .width(180)
.height(7), .height(7),
text(format!( text(measurement).size(12),
"{} of {} ({percent:.1}%)",
format_bytes(progress.downloaded),
format_bytes(progress.total),
))
.size(12),
text(transfer).size(12), text(transfer).size(12),
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
stop, stop,
@@ -486,60 +592,71 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
.into() .into()
} }
fn model_download_status<'a>( fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
download: &ModelDownload,
selected: &DownloadProgress,
) -> Element<'a, Message> {
let (progress, heading, speed, failed) = match download { let (progress, heading, speed, failed) = match download {
ModelDownload::Idle => ( ModelDownload::Idle => unreachable!("idle operations are not displayed"),
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::Active(active) => ( ModelDownload::Active(active) => (
&active.progress, &active.progress,
if active.stopping { if active.stopping {
format!("Stopping {}", active.model) format!("Stopping {}", active.artifact)
} else { } else {
phase_text(active.progress.phase) phase_text(active.progress.phase)
}, },
active.bytes_per_second, active.bytes_per_second,
false, false,
), ),
ModelDownload::Complete(model, progress) => ( ModelDownload::Complete(artifact, operation, progress) => (
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, 0.0,
false, false,
), ),
ModelDownload::Failed(model, error, progress) => ( ModelDownload::Failed(artifact, error, progress) => (
progress, progress,
format!("{model} download failed: {error}"), format!("{artifact} operation failed: {error}"),
0.0, 0.0,
true, true,
), ),
}; };
let percent = progress.fraction() * 100.0; let percent = progress.fraction() * 100.0;
let mut status = column![ let measurement = if progress.verification.is_some() {
if failed { format!(
"{} verified of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
format_bytes(progress.remaining()),
)
} 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) text(heading).size(12).style(iced::widget::text::danger)
} else { } else {
text(heading).size(12) 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), progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(format!( text(measurement).size(12),
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.downloaded),
format_bytes(progress.total),
format_bytes(progress.remaining()),
))
.size(12),
] ]
.spacing(6); .spacing(6);
if speed > 0.0 && progress.remaining() > 0 { 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 { fn sidebar_style(_: &Theme) -> container::Style {
container::Style::default().background(Color::from_rgb8(23, 23, 25)) 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)); 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] #[test]
fn download_measurements_are_readable() { fn download_measurements_are_readable() {
assert_eq!(format_bytes(1_500_000_000), "1.5 GB"); assert_eq!(format_bytes(1_500_000_000), "1.5 GB");

View File

@@ -1,19 +1,24 @@
mod app; mod app;
mod database; mod database;
mod model; mod model;
#[cfg(target_os = "macos")]
mod native_menu;
mod schema; mod schema;
use app::{App, app_theme}; use app::{App, Message, app_icon, app_theme};
use iced::{Size, Task}; use iced::{Size, window};
fn main() -> iced::Result { fn main() -> iced::Result {
iced::application("DS4Server", App::update, App::view) iced::daemon(App::title, App::update, App::view)
.subscription(App::subscription) .subscription(App::subscription)
.theme(|_| app_theme()) .theme(|_, _| app_theme())
.window(iced::window::Settings { .run_with(|| {
let (main_window, open) = window::open(window::Settings {
size: Size::new(1120.0, 720.0), size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)), min_size: Some(Size::new(760.0, 480.0)),
icon: Some(app_icon()),
..Default::default() ..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::fs::{self, File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -11,6 +11,12 @@ pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Pro, ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52, 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 DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf";
const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf"; const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf";
@@ -69,6 +75,7 @@ impl ModelChoice {
self == Self::DeepSeekV4Flash self == Self::DeepSeekV4Flash
} }
#[cfg(test)]
fn main_artifact(self) -> &'static Artifact { fn main_artifact(self) -> &'static Artifact {
match self { match self {
Self::DeepSeekV4Flash => &FLASH, Self::DeepSeekV4Flash => &FLASH,
@@ -77,6 +84,7 @@ impl ModelChoice {
} }
} }
#[cfg(test)]
fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> { fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> {
[ [
Some(self.main_artifact()), Some(self.main_artifact()),
@@ -85,11 +93,63 @@ impl ModelChoice {
.into_iter() .into_iter()
.flatten() .flatten()
} }
}
pub(crate) fn download_size(self, dspark_enabled: bool) -> u64 { #[derive(Clone, Copy, Debug, Eq, PartialEq)]
self.artifacts(dspark_enabled) pub(crate) enum ManagedArtifactId {
.map(|artifact| artifact.size) DeepSeekV4Flash,
.sum() 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) downloaded: u64,
pub(crate) total: u64, pub(crate) total: u64,
pub(crate) phase: DownloadPhase, 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 { impl DownloadProgress {
pub(crate) fn remaining(&self) -> u64 { 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 { pub(crate) fn fraction(&self) -> f32 {
if self.total == 0 { let total = self.active_total();
if total == 0 {
0.0 0.0
} else { } 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -183,58 +261,184 @@ impl Artifact {
.or_else(|_| self.partial_path(model, models_path).metadata()) .or_else(|_| self.partial_path(model, models_path).metadata())
.map_or(0, |metadata| metadata.len().min(self.size)) .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( pub(crate) fn managed_artifacts(models_path: &Path) -> Vec<ManagedArtifact> {
model: ModelChoice, MANAGED_ARTIFACTS
dspark_enabled: bool, .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, models_path: &Path,
) -> DownloadProgress { ) -> DownloadProgress {
let mut downloaded = 0; let model = id.model();
let mut phase = DownloadPhase::Complete; let artifact = id.artifact();
for artifact in model.artifacts(dspark_enabled) { let downloaded = artifact.downloaded_bytes(model, models_path);
let bytes = artifact.downloaded_bytes(model, models_path); let installed = artifact.is_installed(model, models_path);
downloaded += bytes; let verification =
if phase == DownloadPhase::Complete && !artifact.is_installed(model, models_path) { (!installed && downloaded >= artifact.size).then_some(VerificationProgress {
phase = if bytes >= artifact.size { verified: 0,
total: artifact.size,
});
let phase = if installed {
DownloadPhase::Complete
} else if verification.is_some() {
DownloadPhase::Verifying(artifact.label) DownloadPhase::Verifying(artifact.label)
} else if bytes > 0 { } else if downloaded > 0 {
DownloadPhase::Downloading(artifact.label) DownloadPhase::Downloading(artifact.label)
} else { } else {
DownloadPhase::Pending(artifact.label) DownloadPhase::Pending(artifact.label)
}; };
}
}
DownloadProgress { DownloadProgress {
downloaded, downloaded,
total: model.download_size(dspark_enabled), total: artifact.size,
phase, phase,
verification,
} }
} }
pub(crate) fn download( pub(crate) fn artifact_verification_progress(
model: ModelChoice, id: ManagedArtifactId,
dspark_enabled: bool, 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, models_path: &Path,
cancel: &AtomicBool, cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> { ) -> Result<DownloadOutcome, String> {
for artifact in model.artifacts(dspark_enabled) { download_artifact_with_cancel(
if download_artifact_with_cancel(model, artifact, models_path, cancel)? id.model(),
== DownloadOutcome::Stopped 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 Ok(DownloadOutcome::Stopped); 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) 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)] #[cfg(test)]
fn download_artifact( fn download_artifact(
model: ModelChoice, model: ModelChoice,
artifact: &Artifact, artifact: &Artifact,
models_path: &Path, models_path: &Path,
) -> Result<DownloadOutcome, String> { ) -> 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( fn download_artifact_with_cancel(
@@ -242,6 +446,7 @@ fn download_artifact_with_cancel(
artifact: &Artifact, artifact: &Artifact,
models_path: &Path, models_path: &Path,
cancel: &AtomicBool, cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> { ) -> Result<DownloadOutcome, String> {
if cancel.load(Ordering::Relaxed) { if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped); return Ok(DownloadOutcome::Stopped);
@@ -251,7 +456,7 @@ fn download_artifact_with_cancel(
return Ok(DownloadOutcome::Complete); return Ok(DownloadOutcome::Complete);
} }
if destination.exists() { if destination.exists() {
if verify(&destination, artifact, cancel)? == DownloadOutcome::Stopped { if verify(&destination, artifact, cancel, verified_bytes)? == DownloadOutcome::Stopped {
return Ok(DownloadOutcome::Stopped); return Ok(DownloadOutcome::Stopped);
} }
mark_verified(model, artifact, models_path)?; mark_verified(model, artifact, models_path)?;
@@ -276,7 +481,7 @@ fn download_artifact_with_cancel(
if cancel.load(Ordering::Relaxed) { if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped); 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::Stopped) => return Ok(DownloadOutcome::Stopped),
Ok(DownloadOutcome::Complete) => {} Ok(DownloadOutcome::Complete) => {}
Err(error) => { Err(error) => {
@@ -307,7 +512,9 @@ fn verify(
path: &Path, path: &Path,
artifact: &Artifact, artifact: &Artifact,
cancel: &AtomicBool, cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> { ) -> Result<DownloadOutcome, String> {
verified_bytes.store(0, Ordering::Relaxed);
let size = path.metadata().map_err(|error| error.to_string())?.len(); let size = path.metadata().map_err(|error| error.to_string())?.len();
if size != artifact.size { if size != artifact.size {
return Err(format!( return Err(format!(
@@ -329,6 +536,7 @@ fn verify(
break; break;
} }
hasher.update(&buffer[..count]); hasher.update(&buffer[..count]);
verified_bytes.fetch_add(count as u64, Ordering::Relaxed);
} }
let actual = hex(&hasher.finalize()); let actual = hex(&hasher.finalize());
if actual != artifact.sha256 { if actual != artifact.sha256 {
@@ -476,6 +684,59 @@ mod tests {
fs::remove_dir_all(models_path).unwrap(); 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] #[test]
fn restart_resumes_at_the_existing_partial_byte() { fn restart_resumes_at_the_existing_partial_byte() {
let content = b"restart-resume works"; let content = b"restart-resume works";
@@ -557,6 +818,7 @@ mod tests {
&artifact, &artifact,
&directory, &directory,
&cancel, &cancel,
&AtomicU64::new(0),
) )
.unwrap(), .unwrap(),
DownloadOutcome::Stopped 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
}