diff --git a/vidboard-setup-rs/.gitignore b/vidboard-setup-rs/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/vidboard-setup-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/vidboard-setup-rs/Cargo.lock b/vidboard-setup-rs/Cargo.lock new file mode 100644 index 0000000..ccdd69c --- /dev/null +++ b/vidboard-setup-rs/Cargo.lock @@ -0,0 +1,601 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "vidboard-setup" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/vidboard-setup-rs/Cargo.toml b/vidboard-setup-rs/Cargo.toml new file mode 100644 index 0000000..fdc1634 --- /dev/null +++ b/vidboard-setup-rs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "vidboard-setup" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +crossterm = "0.28" +ratatui = "0.29" diff --git a/vidboard-setup-rs/src/main.rs b/vidboard-setup-rs/src/main.rs new file mode 100644 index 0000000..c207c42 --- /dev/null +++ b/vidboard-setup-rs/src/main.rs @@ -0,0 +1,1479 @@ +use anyhow::{anyhow, Context, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Gauge, Paragraph, Wrap}, + DefaultTerminal, Frame, +}; +use std::{ + fs::{self, File}, + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + path::{Path, PathBuf}, + process::Command, + sync::mpsc::{self, Receiver, Sender}, + thread, + time::{Duration, Instant}, +}; + +const APP: &str = "VidBoard Model Setup"; +const TICK: Duration = Duration::from_millis(90); +const BUF_SIZE: usize = 4 * 1024 * 1024; +const COMFY_PORTS: [u16; 2] = [8188, 8000]; + +fn main() -> Result<()> { + let mut terminal = ratatui::init(); + let result = App::new().run(&mut terminal); + ratatui::restore(); + result +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Screen { + Scan, + Configure, + Copy, + Done, + Error, +} + +#[derive(Clone)] +struct PayloadFile { + label: &'static str, + rel: &'static str, + size: u64, +} + +#[derive(Clone)] +struct CopyItem { + label: String, + src: PathBuf, + dst: PathBuf, + size: u64, + skip: bool, +} + +struct Scan { + root: PathBuf, + comfy_found: bool, + comfy_path: String, + ollama_found: bool, + ollama_path: String, + comfy_total: u64, + ollama_total: u64, + files: Vec, + missing: Vec, +} + +enum WorkerMsg { + ScanDone(Result), + CopyProgress { + index: usize, + total_items: usize, + label: String, + rel: String, + read: u64, + size: u64, + overall: u64, + total: u64, + speed_mib: f64, + done: bool, + skipped: bool, + }, + CopyDone { + copied: usize, + skipped: usize, + bytes: u64, + }, + Failed(String), +} + +struct App { + screen: Screen, + tx: Sender, + rx: Receiver, + + scan: Option, + error: Option, + logs: Vec, + + comfy_input: String, + ollama_input: String, + focus: usize, + cursor: usize, + + frame_no: u64, + started_at: Instant, + copy_started: Option, + + active_label: String, + active_rel: String, + active_index: usize, + active_total_items: usize, + active_read: u64, + active_size: u64, + overall: u64, + overall_total: u64, + speed_mib: f64, + copied: usize, + skipped: usize, + bytes_written: u64, +} + +impl App { + fn new() -> Self { + let (tx, rx) = mpsc::channel(); + let mut app = Self { + screen: Screen::Scan, + tx, + rx, + scan: None, + error: None, + logs: vec!["Started.".into()], + comfy_input: String::new(), + ollama_input: String::new(), + focus: 0, + cursor: 0, + frame_no: 0, + started_at: Instant::now(), + copy_started: None, + active_label: "Preparing".into(), + active_rel: String::new(), + active_index: 0, + active_total_items: 1, + active_read: 0, + active_size: 0, + overall: 0, + overall_total: 1, + speed_mib: 0.0, + copied: 0, + skipped: 0, + bytes_written: 0, + }; + app.start_scan(); + app + } + + fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { + loop { + self.drain_worker(); + terminal.draw(|frame| self.draw(frame))?; + + if event::poll(TICK)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && self.handle_key(key.code, key.modifiers) { + return Ok(()); + } + } + } + self.frame_no = self.frame_no.wrapping_add(1); + } + } + + fn start_scan(&mut self) { + let tx = self.tx.clone(); + thread::spawn(move || { + let _ = tx.send(WorkerMsg::ScanDone(scan_system())); + }); + } + + fn start_copy(&mut self) { + let Some(scan) = &self.scan else { return }; + let comfy = PathBuf::from(self.comfy_input.trim()); + let ollama = if self.ollama_input.trim().is_empty() { + None + } else { + Some(PathBuf::from(self.ollama_input.trim())) + }; + let root = scan.root.clone(); + let tx = self.tx.clone(); + self.screen = Screen::Copy; + self.copy_started = Some(Instant::now()); + self.logs.push("Copy started.".into()); + thread::spawn(move || { + if let Err(err) = copy_worker(root, comfy, ollama, tx.clone()) { + let _ = tx.send(WorkerMsg::Failed(err.to_string())); + } + }); + } + + fn drain_worker(&mut self) { + while let Ok(msg) = self.rx.try_recv() { + match msg { + WorkerMsg::ScanDone(Ok(scan)) => { + if !scan.missing.is_empty() { + self.screen = Screen::Error; + self.error = + Some(format!("Missing source files: {}", scan.missing.join(", "))); + continue; + } + self.comfy_input = scan.comfy_path.clone(); + self.ollama_input = scan.ollama_path.clone(); + self.cursor = self.comfy_input.len(); + self.logs.push(format!( + "ComfyUI: {}", + if scan.comfy_found { + "detected" + } else { + "manual path needed" + } + )); + self.logs.push(format!( + "Ollama: {}", + if scan.ollama_found { + "detected" + } else { + "optional/manual" + } + )); + self.scan = Some(scan); + self.screen = Screen::Configure; + } + WorkerMsg::ScanDone(Err(err)) => { + self.screen = Screen::Error; + self.error = Some(err.to_string()); + } + WorkerMsg::CopyProgress { + index, + total_items, + label, + rel, + read, + size, + overall, + total, + speed_mib, + done, + skipped, + } => { + self.active_index = index; + self.active_total_items = total_items.max(1); + self.active_label = label.clone(); + self.active_rel = rel; + self.active_read = read; + self.active_size = size; + self.overall = overall; + self.overall_total = total.max(1); + self.speed_mib = speed_mib; + if done { + if skipped { + self.skipped += 1; + self.logs.push(format!("Skipped {label}.")); + } else { + self.copied += 1; + self.logs.push(format!("Copied {label}.")); + } + } + } + WorkerMsg::CopyDone { + copied, + skipped, + bytes, + } => { + self.copied = copied; + self.skipped = skipped; + self.bytes_written = bytes; + self.screen = Screen::Done; + self.logs.push("Complete.".into()); + } + WorkerMsg::Failed(message) => { + self.screen = Screen::Error; + self.error = Some(message); + } + } + } + } + + fn handle_key(&mut self, code: KeyCode, mods: KeyModifiers) -> bool { + if mods.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c')) { + return self.screen != Screen::Copy; + } + + match self.screen { + Screen::Scan => return code == KeyCode::Esc, + Screen::Configure => match code { + KeyCode::Esc => return true, + KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => { + self.focus = 1 - self.focus; + self.cursor = self.active_input().len(); + } + KeyCode::Enter => { + if self.comfy_input.trim().is_empty() { + self.logs.push("ComfyUI path is required.".into()); + } else { + self.start_copy(); + } + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.active_input().len()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.active_input().len(), + KeyCode::Backspace => { + if self.cursor > 0 { + self.cursor -= 1; + let cursor = self.cursor; + self.active_input_mut().remove(cursor); + } + } + KeyCode::Delete => { + let cursor = self.cursor; + if cursor < self.active_input().len() { + self.active_input_mut().remove(cursor); + } + } + KeyCode::Char(ch) if !mods.contains(KeyModifiers::CONTROL) => { + let cursor = self.cursor; + self.active_input_mut().insert(cursor, ch); + self.cursor += ch.len_utf8(); + } + _ => {} + }, + Screen::Copy => { + if code == KeyCode::Esc { + self.logs + .push("Copy is running; close only if you need to abort.".into()); + } + } + Screen::Done | Screen::Error => { + if matches!(code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')) { + return true; + } + } + } + false + } + + fn active_input(&self) -> &str { + if self.focus == 0 { + &self.comfy_input + } else { + &self.ollama_input + } + } + + fn active_input_mut(&mut self) -> &mut String { + if self.focus == 0 { + &mut self.comfy_input + } else { + &mut self.ollama_input + } + } + + fn draw(&self, frame: &mut Frame) { + let area = app_area(frame.area()); + frame.render_widget(Clear, area); + let [header, body, footer] = Layout::vertical([ + Constraint::Length(4), + Constraint::Min(10), + Constraint::Length(1), + ]) + .areas(area); + self.draw_header(frame, header); + match self.screen { + Screen::Scan => self.draw_scan(frame, body), + Screen::Configure => self.draw_config(frame, body), + Screen::Copy => self.draw_copy(frame, body), + Screen::Done => self.draw_done(frame, body), + Screen::Error => self.draw_error(frame, body), + } + self.draw_footer(frame, footer); + } + + fn draw_header(&self, frame: &mut Frame, area: Rect) { + let status = match self.screen { + Screen::Scan => ("SCAN", Color::Cyan), + Screen::Configure => ("CONFIGURE", Color::Yellow), + Screen::Copy => ("COPY", Color::Cyan), + Screen::Done => ("DONE", Color::Green), + Screen::Error => ("ERROR", Color::Red), + }; + let [top, sep] = + Layout::vertical([Constraint::Length(2), Constraint::Length(1)]).areas(area); + let [left, right] = + Layout::horizontal([Constraint::Min(30), Constraint::Length(18)]).areas(top); + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + APP, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " models -> local install", + Style::default().fg(Color::DarkGray), + ), + ]), + Line::from(Span::styled( + "ComfyUI + Ollama payload copier", + Style::default().fg(Color::DarkGray), + )), + ]), + left, + ); + frame.render_widget( + Paragraph::new(Span::styled( + status.0, + Style::default().fg(status.1).add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + right, + ); + frame.render_widget(Paragraph::new(rule(sep.width)), sep); + } + + fn draw_scan(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border()); + frame.render_widget(block, area); + let text = Text::from(vec![ + Line::from(vec![ + Span::styled(spinner(self.frame_no), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(" Detecting ComfyUI and Ollama"), + ]), + Line::raw(""), + Line::from(Span::styled("Checking ports 8188 and 8000, process command lines, env vars, and known install folders.", Style::default().fg(Color::Gray))), + Line::raw(""), + scan_bar(self.frame_no, area.width.saturating_sub(10) as usize), + ]); + frame.render_widget( + Paragraph::new(text).alignment(Alignment::Center), + inset(area, 4, 4), + ); + } + + fn draw_config(&self, frame: &mut Frame, area: Rect) { + let [summary, comfy, ollama, lower] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(8), + ]) + .spacing(1) + .areas(area); + + self.draw_summary(frame, summary); + self.draw_input_row( + frame, + comfy, + "ComfyUI", + &self.comfy_input, + self.focus == 0, + self.scan.as_ref().map(|s| s.comfy_found).unwrap_or(false), + ); + self.draw_input_row( + frame, + ollama, + "Ollama", + &self.ollama_input, + self.focus == 1, + self.scan.as_ref().map(|s| s.ollama_found).unwrap_or(false), + ); + + let [payload, log] = + Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]) + .spacing(2) + .areas(lower); + self.draw_payload(frame, payload); + self.draw_log(frame, log); + } + + fn draw_summary(&self, frame: &mut Frame, area: Rect) { + let Some(scan) = &self.scan else { return }; + let line = Line::from(vec![ + Span::styled( + "READY", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("payload ", Style::default().fg(Color::DarkGray)), + Span::styled( + bytes(scan.comfy_total + scan.ollama_total), + Style::default().fg(Color::White), + ), + Span::raw(" "), + Span::styled("runtime ", Style::default().fg(Color::DarkGray)), + Span::styled( + humantime(self.started_at.elapsed()), + Style::default().fg(Color::White), + ), + Span::raw(" "), + Span::styled( + "Enter", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" install "), + Span::styled( + "Tab", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" switch field"), + ]); + frame.render_widget(Paragraph::new(line).block(row_block(false)), area); + } + + fn draw_input_row( + &self, + frame: &mut Frame, + area: Rect, + label: &str, + value: &str, + focused: bool, + detected: bool, + ) { + let state = if detected { "detected" } else { "manual" }; + let marker = if focused { ">" } else { " " }; + let value_width = area.width.saturating_sub(28) as usize; + let shown = if focused { + edit_view(value, self.cursor, value_width) + } else if value.trim().is_empty() { + "not set".to_string() + } else { + fit_end(value, value_width) + }; + let line = Line::from(vec![ + Span::styled( + marker, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("{label:<8}"), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{state:<10}"), + Style::default().fg(if detected { + Color::Green + } else { + Color::Yellow + }), + ), + Span::styled( + shown, + Style::default().fg(if value.trim().is_empty() { + Color::DarkGray + } else { + Color::White + }), + ), + ]); + frame.render_widget(Paragraph::new(line).block(row_block(focused)), area); + } + + fn draw_payload(&self, frame: &mut Frame, area: Rect) { + frame.render_widget(panel("Payload"), area); + let Some(scan) = &self.scan else { return }; + let mut lines = vec![ + Line::from(metric("ComfyUI", bytes(scan.comfy_total))), + Line::from(metric("Ollama", bytes(scan.ollama_total))), + Line::raw(""), + ]; + for file in &scan.files { + lines.push(Line::from(vec![ + Span::styled("ok ", Style::default().fg(Color::Green)), + Span::styled(file.label, Style::default().fg(Color::White)), + Span::styled( + format!(" {}", bytes(file.size)), + Style::default().fg(Color::DarkGray), + ), + ])); + } + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }), + inset(area, 2, 1), + ); + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + frame.render_widget(panel("Activity"), area); + let max_lines = area.height.saturating_sub(2) as usize; + let start = self.logs.len().saturating_sub(max_lines); + let lines: Vec = self.logs[start..] + .iter() + .map(|log| { + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::DarkGray)), + Span::styled( + fit_end(log, area.width.saturating_sub(6) as usize), + Style::default().fg(Color::Gray), + ), + ]) + }) + .collect(); + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }), + inset(area, 2, 1), + ); + } + + fn draw_copy(&self, frame: &mut Frame, area: Rect) { + let [top, gauge_area, lower] = Layout::vertical([ + Constraint::Length(5), + Constraint::Length(3), + Constraint::Min(8), + ]) + .spacing(1) + .areas(area); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + &self.active_label, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + &self.active_rel, + Style::default().fg(Color::DarkGray), + )), + Line::from(metric( + "Item", + format!("{} of {}", self.active_index + 1, self.active_total_items), + )), + ]) + .block(panel_block("Current file")), + top, + ); + frame.render_widget(copy_gauge(self.overall, self.overall_total), gauge_area); + let [stats, log] = Layout::horizontal([Constraint::Length(38), Constraint::Min(20)]) + .spacing(2) + .areas(lower); + let elapsed = self + .copy_started + .map(|t| humantime(t.elapsed())) + .unwrap_or_else(|| "-".into()); + let stats_text = vec![ + Line::from(metric( + "Current", + format!("{:.1}%", pct(self.active_read, self.active_size)), + )), + Line::from(metric( + "Read", + format!("{} / {}", bytes(self.active_read), bytes(self.active_size)), + )), + Line::from(metric( + "Overall", + format!("{} / {}", bytes(self.overall), bytes(self.overall_total)), + )), + Line::from(metric("Speed", format!("{:.1} MiB/s", self.speed_mib))), + Line::from(metric("Elapsed", elapsed)), + Line::raw(""), + quiet_transfer_line(self.frame_no, stats.width.saturating_sub(4) as usize), + ]; + frame.render_widget( + Paragraph::new(stats_text).block(panel_block("Stats")), + stats, + ); + self.draw_log(frame, log); + } + + fn draw_done(&self, frame: &mut Frame, area: Rect) { + let text = vec![ + Line::from(Span::styled( + "Complete", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(metric("Copied", format!("{} files", self.copied))), + Line::from(metric("Skipped", format!("{} files", self.skipped))), + Line::from(metric("Written", bytes(self.bytes_written))), + Line::raw(""), + Line::from(Span::styled( + "Press Enter to close.", + Style::default().fg(Color::DarkGray), + )), + ]; + frame.render_widget( + Paragraph::new(text) + .alignment(Alignment::Center) + .block(panel_block("Done")), + area, + ); + } + + fn draw_error(&self, frame: &mut Frame, area: Rect) { + let msg = self.error.as_deref().unwrap_or("Unknown error"); + let text = vec![ + Line::from(Span::styled( + "Setup stopped", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(Span::styled(msg, Style::default().fg(Color::White))), + Line::raw(""), + Line::from(Span::styled( + "Press Enter to close.", + Style::default().fg(Color::DarkGray), + )), + ]; + frame.render_widget( + Paragraph::new(text) + .wrap(Wrap { trim: true }) + .block(panel_block("Error")), + area, + ); + } + + fn draw_footer(&self, frame: &mut Frame, area: Rect) { + let text = match self.screen { + Screen::Scan => "Esc quit", + Screen::Configure => "Tab switch field Enter install Esc quit", + Screen::Copy => "Copying. Do not close this window unless you need to abort.", + Screen::Done | Screen::Error => "Enter close", + }; + frame.render_widget( + Paragraph::new(text) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center), + area, + ); + } +} + +fn scan_system() -> Result { + let root = std::env::current_exe() + .context("cannot resolve current executable")? + .parent() + .ok_or_else(|| anyhow!("cannot resolve executable directory"))? + .to_path_buf(); + + let mut files = Vec::new(); + let mut missing = Vec::new(); + let mut comfy_total = 0; + for mut payload in comfy_payload() { + let path = root.join("models").join("comfyui").join(payload.rel); + match fs::metadata(&path) { + Ok(meta) => { + payload.size = meta.len(); + comfy_total += payload.size; + files.push(payload); + } + Err(_) => missing.push(format!("models\\comfyui\\{}", payload.rel)), + } + } + + for (model, tag) in [("gemma3", "12b"), ("gemma4", "e4b")] { + let manifest = root + .join("models") + .join("ollama") + .join("manifests") + .join("registry.ollama.ai") + .join("library") + .join(model) + .join(tag); + if !manifest.exists() { + missing.push(format!( + "models\\ollama\\manifests\\registry.ollama.ai\\library\\{model}\\{tag}" + )); + } + } + let ollama_total = dir_size(&root.join("models").join("ollama")).unwrap_or(0); + let (comfy_path, comfy_found) = detect_comfyui(); + let (ollama_path, ollama_found) = detect_ollama(); + + Ok(Scan { + root, + comfy_found, + comfy_path, + ollama_found, + ollama_path, + comfy_total, + ollama_total, + files, + missing, + }) +} + +fn copy_worker( + root: PathBuf, + comfy: PathBuf, + ollama: Option, + tx: Sender, +) -> Result<()> { + let (items, total) = copy_plan(&root, &comfy, ollama.as_deref())?; + let mut overall = 0; + let mut copied = 0; + let mut skipped = 0; + let mut bytes_written = 0; + let total_items = items.len(); + + for (index, item) in items.iter().enumerate() { + if item.skip { + overall += item.size; + skipped += 1; + tx.send(WorkerMsg::CopyProgress { + index, + total_items, + label: item.label.clone(), + rel: rel_display(&item.dst), + read: item.size, + size: item.size, + overall, + total, + speed_mib: 0.0, + done: true, + skipped: true, + })?; + continue; + } + + fs::create_dir_all(item.dst.parent().unwrap_or_else(|| Path::new(".")))?; + let mut input = + File::open(&item.src).with_context(|| format!("open {}", item.src.display()))?; + let mut output = + File::create(&item.dst).with_context(|| format!("create {}", item.dst.display()))?; + let started = Instant::now(); + let mut last = Instant::now(); + let mut read_total = 0; + let mut buf = vec![0_u8; BUF_SIZE]; + + loop { + let n = input.read(&mut buf)?; + if n == 0 { + break; + } + output.write_all(&buf[..n])?; + read_total += n as u64; + overall += n as u64; + if last.elapsed() >= TICK || read_total == item.size { + let speed_mib = read_total as f64 + / started.elapsed().as_secs_f64().max(0.001) + / 1024.0 + / 1024.0; + tx.send(WorkerMsg::CopyProgress { + index, + total_items, + label: item.label.clone(), + rel: rel_display(&item.dst), + read: read_total, + size: item.size, + overall, + total, + speed_mib, + done: false, + skipped: false, + })?; + last = Instant::now(); + } + } + output.sync_all()?; + copied += 1; + bytes_written += read_total; + tx.send(WorkerMsg::CopyProgress { + index, + total_items, + label: item.label.clone(), + rel: rel_display(&item.dst), + read: read_total, + size: item.size, + overall, + total, + speed_mib: read_total as f64 + / started.elapsed().as_secs_f64().max(0.001) + / 1024.0 + / 1024.0, + done: true, + skipped: false, + })?; + } + tx.send(WorkerMsg::CopyDone { + copied, + skipped, + bytes: bytes_written, + })?; + Ok(()) +} + +fn copy_plan(root: &Path, comfy: &Path, ollama: Option<&Path>) -> Result<(Vec, u64)> { + let mut items = Vec::new(); + for payload in comfy_payload() { + add_item( + &mut items, + payload.label, + root.join("models").join("comfyui").join(payload.rel), + comfy.join("models").join(payload.rel), + )?; + } + if let Some(ollama) = ollama { + collect_ollama( + root.join("models").join("ollama"), + ollama.to_path_buf(), + &mut items, + )?; + } + items.sort_by(|a, b| b.size.cmp(&a.size)); + let total = items.iter().map(|i| i.size).sum(); + Ok((items, total)) +} + +fn add_item( + items: &mut Vec, + label: impl Into, + src: PathBuf, + dst: PathBuf, +) -> Result<()> { + let size = fs::metadata(&src)?.len(); + let skip = destination_matches(&dst, size); + items.push(CopyItem { + label: label.into(), + src, + dst, + size, + skip, + }); + Ok(()) +} + +fn destination_matches(dst: &Path, source_size: u64) -> bool { + let Ok(link_meta) = fs::symlink_metadata(dst) else { + return false; + }; + if link_meta.file_type().is_symlink() { + return fs::metadata(dst) + .map(|target_meta| target_meta.is_file() && target_meta.len() == source_size) + .unwrap_or(false); + } + link_meta.is_file() && link_meta.len() == source_size +} + +fn collect_ollama(src_root: PathBuf, dst_root: PathBuf, items: &mut Vec) -> Result<()> { + fn walk(base: &Path, path: &Path, dst_root: &Path, items: &mut Vec) -> Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + walk(base, &path, dst_root, items)?; + } else { + let rel = path.strip_prefix(base)?; + let rel_text = rel.to_string_lossy(); + let label = if rel_text.contains("manifests") { + format!("Ollama manifest {}", rel_text.replace('\\', "/")) + } else { + format!( + "Ollama {}", + path.file_name().unwrap_or_default().to_string_lossy() + ) + }; + add_item(items, label, path.clone(), dst_root.join(rel))?; + } + } + Ok(()) + } + walk(&src_root, &src_root, &dst_root, items) +} + +fn comfy_payload() -> Vec { + vec![ + PayloadFile { + label: "Flux 2 Klein 4b fp8", + rel: "diffusion_models\\flux-2-klein-4b-fp8.safetensors", + size: 0, + }, + PayloadFile { + label: "Flux 2 Klein 9b fp8", + rel: "diffusion_models\\flux-2-klein-9b-fp8.safetensors", + size: 0, + }, + PayloadFile { + label: "Qwen text encoder", + rel: "text_encoders\\qwen_3_4b.safetensors", + size: 0, + }, + PayloadFile { + label: "Qwen text encoder 8b", + rel: "text_encoders\\qwen_3_8b_fp8mixed.safetensors", + size: 0, + }, + PayloadFile { + label: "Flux 2 VAE", + rel: "vae\\flux2-vae.safetensors", + size: 0, + }, + ] +} + +fn detect_ollama() -> (String, bool) { + if let Ok(value) = std::env::var("OLLAMA_MODELS") { + if !value.trim().is_empty() && Path::new(&value).exists() { + return (value, true); + } + } + if let Some(path) = env_path_from_process("ollama.exe", "OLLAMA_MODELS") { + return (path, true); + } + if let Ok(profile) = std::env::var("USERPROFILE") { + let path = PathBuf::from(profile).join(".ollama").join("models"); + if path.exists() { + return (path.to_string_lossy().to_string(), true); + } + } + (String::new(), false) +} + +fn detect_comfyui() -> (String, bool) { + for key in ["COMFYUI_PATH", "COMFYUI_HOME", "COMFYUI_ROOT"] { + if let Ok(value) = std::env::var(key) { + if let Some(root) = normalize_comfy_target(&PathBuf::from(value.trim())) { + return (root.to_string_lossy().to_string(), true); + } + } + } + if let Some(path) = comfy_from_port() { + return (path, true); + } + if let Some(path) = comfy_from_process_list() { + return (path, true); + } + if let Some(path) = comfy_from_known_locations() { + return (path, true); + } + (String::new(), false) +} + +fn comfy_from_port() -> Option { + for port in COMFY_PORTS { + if !localhost_port_open(port) { + continue; + } + let pid = ps(&format!("(Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess")).ok()?; + let pid = pid.trim(); + if pid.is_empty() { + continue; + } + let command = ps(&format!( + "(Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\").CommandLine" + )) + .ok()?; + if let Some(root) = comfy_target_from_command_line(&command) { + return Some(root.to_string_lossy().to_string()); + } + let executable = ps(&format!( + "(Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\").ExecutablePath" + )) + .ok()?; + if let Some(root) = comfy_from_ancestor(Path::new(executable.trim())) { + return Some(root.to_string_lossy().to_string()); + } + } + None +} + +fn comfy_from_process_list() -> Option { + let script = r#" +Get-CimInstance Win32_Process | + Where-Object { + $_.CommandLine -and ( + $_.CommandLine -match 'ComfyUI' -or + $_.CommandLine -match 'main\.py' -or + $_.CommandLine -match 'comfy' + ) + } | + ForEach-Object { $_.CommandLine } +"#; + let out = ps(script).ok()?; + for line in out.lines() { + if let Some(root) = comfy_target_from_command_line(line) { + return Some(root.to_string_lossy().to_string()); + } + } + None +} + +fn comfy_target_from_command_line(command: &str) -> Option { + let tokens = tokenize(command); + if let Some(path) = arg_path(&tokens, "--base-directory") { + if let Some(root) = normalize_comfy_target(&path) { + return Some(root); + } + } + for token in tokens { + let path = PathBuf::from(&token); + if token.to_ascii_lowercase().ends_with("main.py") { + if path.is_absolute() { + if let Some(parent) = path.parent().and_then(normalize_comfy_target) { + return Some(parent); + } + } + continue; + } + if path.is_absolute() { + if let Some(root) = normalize_comfy_target(&path) { + return Some(root); + } + if let Some(root) = comfy_from_ancestor(&path) { + return Some(root); + } + } + } + None +} + +fn comfy_from_known_locations() -> Option { + let mut candidates = Vec::new(); + if let Ok(profile) = std::env::var("USERPROFILE") { + let profile = PathBuf::from(profile); + candidates.push(profile.join("Documents").join("ComfyUI")); + candidates.push(profile.join("ComfyUI")); + candidates.push(profile.join("Desktop").join("ComfyUI")); + } + for drive in filesystem_roots() { + candidates.push(drive.join("ComfyUI")); + candidates.push(drive.join("ComfyUI_windows_portable").join("ComfyUI")); + candidates.push(drive.join("AI").join("ComfyUI")); + candidates.push(drive.join("StableDiffusion").join("ComfyUI")); + } + for candidate in candidates { + if let Some(root) = normalize_comfy_target(&candidate) { + return Some(root.to_string_lossy().to_string()); + } + } + None +} + +fn normalize_comfy_target(path: &Path) -> Option { + let path = if path.is_file() { + path.parent()?.to_path_buf() + } else { + path.to_path_buf() + }; + if is_comfy_target(&path) { + return Some(path); + } + let nested = path.join("ComfyUI"); + if is_comfy_target(&nested) { + return Some(nested); + } + None +} + +fn is_comfy_target(path: &Path) -> bool { + path.join("models").is_dir() + && (path.join("main.py").is_file() + || path.join("user").is_dir() + || path.join("input").is_dir() + || path.join("output").is_dir() + || path.join("custom_nodes").is_dir()) +} + +fn comfy_from_ancestor(path: &Path) -> Option { + for ancestor in path.ancestors().take(8) { + if let Some(root) = normalize_comfy_target(ancestor) { + return Some(root); + } + } + None +} + +fn arg_path(tokens: &[String], flag: &str) -> Option { + for (i, token) in tokens.iter().enumerate() { + if token == flag { + return tokens.get(i + 1).map(PathBuf::from); + } + if let Some(value) = token.strip_prefix(&format!("{flag}=")) { + return Some(PathBuf::from(value)); + } + } + None +} + +fn filesystem_roots() -> Vec { + if let Ok(drives) = ps("Get-PSDrive -PSProvider FileSystem | ForEach-Object { $_.Root }") { + return drives + .lines() + .map(str::trim) + .filter(|drive| !drive.is_empty()) + .map(PathBuf::from) + .collect(); + } + ["C:\\", "D:\\", "E:\\", "F:\\", "G:\\", "A:\\"] + .into_iter() + .map(PathBuf::from) + .collect() +} + +fn localhost_port_open(port: u16) -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + TcpStream::connect_timeout(&addr, Duration::from_millis(120)).is_ok() +} + +fn env_path_from_process(process: &str, key: &str) -> Option { + let command = format!( + "$p = Get-CimInstance Win32_Process -Filter \"Name = '{process}'\" | Select-Object -First 1; if ($p) {{ $p.CommandLine }}" + ); + let out = ps(&command).ok()?; + let lower = out.to_lowercase(); + let marker = format!("{}=", key.to_lowercase()); + let index = lower.find(&marker)?; + let raw = out[index + marker.len()..] + .trim() + .trim_matches(|c| c == '"' || c == '\''); + let first = raw + .split_whitespace() + .next()? + .trim_matches(|c| c == '"' || c == '\''); + if Path::new(first).exists() { + Some(first.to_string()) + } else { + None + } +} + +fn ps(script: &str) -> Result { + let out = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output()?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn tokenize(s: &str) -> Vec { + let mut out = Vec::new(); + let mut buf = String::new(); + let mut quote = None; + for ch in s.chars() { + match quote { + Some(q) if ch == q => quote = None, + Some(_) => buf.push(ch), + None if ch == '"' || ch == '\'' => quote = Some(ch), + None if ch.is_whitespace() => { + if !buf.is_empty() { + out.push(std::mem::take(&mut buf)); + } + } + None => buf.push(ch), + } + } + if !buf.is_empty() { + out.push(buf); + } + out +} + +fn dir_size(path: &Path) -> Result { + let mut total = 0; + if !path.exists() { + return Ok(0); + } + for entry in fs::read_dir(path)? { + let entry = entry?; + let meta = entry.metadata()?; + if meta.is_dir() { + total += dir_size(&entry.path())?; + } else { + total += meta.len(); + } + } + Ok(total) +} + +fn app_area(area: Rect) -> Rect { + let x = if area.width > 100 { 2 } else { 0 }; + let y = if area.height > 28 { 1 } else { 0 }; + Rect { + x: area.x + x, + y: area.y + y, + width: area.width.saturating_sub(x * 2), + height: area.height.saturating_sub(y * 2), + } +} + +fn row_block(focused: bool) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(if focused { + Style::default().fg(Color::Cyan) + } else { + border() + }) +} + +fn panel(title: &'static str) -> Block<'static> { + panel_block(title) +} + +fn panel_block(title: &'static str) -> Block<'static> { + Block::default() + .title(Span::styled( + format!(" {title} "), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(border()) +} + +fn border() -> Style { + Style::default().fg(Color::Rgb(45, 58, 72)) +} + +fn copy_gauge(done: u64, total: u64) -> Gauge<'static> { + let ratio = if total == 0 { + 0.0 + } else { + done as f64 / total as f64 + } + .clamp(0.0, 1.0); + Gauge::default() + .ratio(ratio) + .label(format!("{:.1}%", ratio * 100.0)) + .gauge_style( + Style::default() + .fg(Color::Cyan) + .bg(Color::Rgb(32, 38, 46)) + .add_modifier(Modifier::BOLD), + ) +} + +fn metric(k: &str, v: impl Into) -> Vec> { + vec![ + Span::styled(format!("{k:<9}"), Style::default().fg(Color::DarkGray)), + Span::styled(v.into(), Style::default().fg(Color::White)), + ] +} + +fn inset(area: Rect, x: u16, y: u16) -> Rect { + Rect { + x: area.x + x, + y: area.y + y, + width: area.width.saturating_sub(x * 2), + height: area.height.saturating_sub(y * 2), + } +} + +fn spinner(frame: u64) -> &'static str { + ["·", "•", "●", "•"][(frame as usize / 3) % 4] +} + +fn scan_bar(frame: u64, width: usize) -> Line<'static> { + let width = width.clamp(12, 70); + let pos = (frame as usize / 2) % width; + let mut spans = Vec::new(); + for i in 0..width { + let color = if i == pos { + Color::Cyan + } else { + Color::Rgb(36, 45, 54) + }; + spans.push(Span::styled("─", Style::default().fg(color))); + } + Line::from(spans) +} + +fn quiet_transfer_line(frame: u64, width: usize) -> Line<'static> { + let width = width.clamp(8, 40); + let pos = (frame as usize / 4) % width; + let mut spans = Vec::new(); + for i in 0..width { + let color = if i == pos { + Color::Cyan + } else { + Color::Rgb(42, 50, 58) + }; + spans.push(Span::styled("━", Style::default().fg(color))); + } + Line::from(spans) +} + +fn rule(width: u16) -> Line<'static> { + Line::from(Span::styled( + "─".repeat(width.saturating_sub(1) as usize), + Style::default().fg(Color::Rgb(34, 42, 50)), + )) +} + +fn edit_view(value: &str, cursor: usize, width: usize) -> String { + if width == 0 { + return String::new(); + } + let cursor = cursor.min(value.len()); + let with_cursor = format!("{}▌{}", &value[..cursor], &value[cursor..]); + fit_around(&with_cursor, cursor, width) +} + +fn fit_around(value: &str, cursor: usize, width: usize) -> String { + if value.len() <= width { + return value.to_string(); + } + if width <= 3 { + return "…".repeat(width); + } + let half = width / 2; + let start = cursor + .saturating_sub(half) + .min(value.len().saturating_sub(width - 1)); + let end = (start + width - 1).min(value.len()); + let prefix = if start > 0 { "…" } else { "" }; + format!("{prefix}{}", &value[start..end]) +} + +fn fit_end(value: &str, width: usize) -> String { + if value.len() <= width { + return value.to_string(); + } + if width <= 3 { + return "…".repeat(width); + } + format!("…{}", &value[value.len() - (width - 1)..]) +} + +fn bytes(n: u64) -> String { + let units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = n as f64; + let mut unit = 0; + while value >= 1024.0 && unit < units.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{n} B") + } else { + format!("{value:.1} {}", units[unit]) + } +} + +fn pct(done: u64, total: u64) -> f64 { + if total == 0 { + 0.0 + } else { + done as f64 / total as f64 * 100.0 + } +} + +fn humantime(d: Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("{secs}s") + } else { + format!("{}m {:02}s", secs / 60, secs % 60) + } +} + +fn rel_display(path: &Path) -> String { + path.components() + .rev() + .take(3) + .collect::>() + .into_iter() + .rev() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("\\") +}