Add model management and macOS integration
This commit is contained in:
@@ -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
533
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
37
PLAN.md
@@ -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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -8,12 +8,13 @@ The current milestone provides a Codex-inspired project/session layout. A native
|
|||||||
macOS folder picker selects each workspace, then the app asks for its display
|
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.
314
src/app.rs
314
src/app.rs
@@ -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());
|
||||||
|
|||||||
368
src/app/view.rs
368
src/app/view.rs
@@ -1,12 +1,14 @@
|
|||||||
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
|
use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path};
|
||||||
use crate::database::{ProjectWithSessions, Session};
|
use crate::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");
|
||||||
|
|||||||
17
src/main.rs
17
src/main.rs
@@ -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()))
|
|
||||||
}
|
}
|
||||||
|
|||||||
326
src/model.rs
326
src/model.rs
@@ -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
90
src/native_menu.rs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user