diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6da0b49..2ade203 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -70,10 +70,10 @@ body: attributes: label: How was it running? options: - - Interactive TUI + - Desktop window (GUI) - Headless (--scan) - Headless with repairs (--auto-fix) - - Simulation (--dry-run, or [D] in the TUI) + - Simulation (--dry-run, or the simulation switch in the window) validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c604559..bc03d85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,11 +100,14 @@ jobs: - uses: taiki-e/install-action@cargo-audit # Plain `cargo audit` fails on a vulnerability and passes on an - # informational warning, which is the policy we want: the three warnings - # open today (RUSTSEC-2024-0436 paste, RUSTSEC-2026-0002 and - # RUSTSEC-2026-0253 lru) all arrive through ratatui and cannot be - # resolved here. A gate that is red for reasons nobody can act on is a - # gate people learn to ignore. Warnings still show up in the job log. + # informational warning, which is the policy we want: a gate that goes red + # for reasons nobody can act on is a gate people learn to ignore, and the + # warnings still show up in the job log either way. + # + # The three this comment used to enumerate (RUSTSEC-2024-0436 `paste`, + # RUSTSEC-2026-0002 and RUSTSEC-2026-0253 `lru`) all arrived through + # ratatui and left with it when the terminal front end was replaced by + # egui. The tree audits clean as of that change. - name: Audit Dependencies run: cargo audit diff --git a/.gitignore b/.gitignore index fcdc8b9..37ee315 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ Thumbs.db *.exe .idea/ .vscode/ +.zcode/ implementation_plan.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f24cde..2588486 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,9 @@ where the risky parts of the codebase are. - **Windows.** The crate is Windows-only and does not cross-compile for development — `winreg` refuses to build on other platforms with a `compile_error!`. A Windows VM works fine. -- **Rust 1.88 or newer.** This is the MSRV declared in `Cargo.toml` and +- **Rust 1.95 or newer.** This is the MSRV declared in `Cargo.toml` and enforced by the `msrv` CI job. Edition 2024 alone would only need 1.85; the - floor comes from `ratatui → instability → darling`. + floor comes from `egui`/`eframe`. - **Administrator rights** to exercise the repair paths by hand. The test suite itself does not need them. diff --git a/Cargo.lock b/Cargo.lock index d3b89d0..3d2ba0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,141 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -12,10 +147,29 @@ dependencies = [ ] [[package]] -name = "allocator-api2" -version = "0.2.21" +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" @@ -62,7 +216,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -73,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -83,14 +237,157 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "approx" -version = "0.5.1" +name = "arboard" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ - "num-traits", + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -103,40 +400,53 @@ dependencies = [ ] [[package]] -name = "atomic" -version = "0.6.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.1" +name = "atspi" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] [[package]] -name = "base64" -version = "0.22.1" +name = "atspi-common" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] [[package]] -name = "bit-set" -version = "0.5.3" +name = "atspi-proxies" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ - "bit-vec", + "atspi-common", + "serde", + "zbus", ] [[package]] -name = "bit-vec" -version = "0.6.3" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" @@ -149,14 +459,8 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "serde_core", ] [[package]] @@ -169,22 +473,67 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.20.3" +name = "block2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] [[package]] -name = "by_address" -version = "1.2.1" +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -193,12 +542,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "castaway" -version = "0.2.4" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "rustversion", + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", ] [[package]] @@ -208,6 +599,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -223,6 +616,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chrono" version = "0.4.45" @@ -278,32 +680,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "colorchoice" -version = "1.0.5" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] [[package]] -name = "colored" +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "compact_str" -version = "0.9.1" +name = "combine" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -313,12 +738,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "convert_case" -version = "0.10.0" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "unicode-segmentation", + "core-foundation-sys", + "libc", ] [[package]] @@ -328,11 +754,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ + "bitflags 1.3.2", + "core-foundation", "libc", ] @@ -346,191 +787,355 @@ dependencies = [ ] [[package]] -name = "critical-section" +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] -name = "crossterm" -version = "0.29.0" +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", + "objc2 0.6.4", ] [[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "winapi", + "libloading", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "generic-array", - "typenum", + "litrs", ] [[package]] -name = "crypto-common" -version = "0.2.2" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc883f505d446814c0226bef4b64564b9adb9966fc00c88c57ce6d827dd152d6" dependencies = [ - "hybrid-array", + "bytemuck", + "emath", + "serde", ] [[package]] -name = "csscolorparser" -version = "0.6.2" +name = "eframe" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2afc0cbcdb6896b7bfb1dbbebaf7b9af9635ff38fd01e89bdd0174c1717b1857" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +checksum = "c977ac91dfaa651633fd9722e4ce9ccb32cda4c748b89a5cb57e504036e37c13" dependencies = [ - "lab", - "phf", + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", ] [[package]] -name = "darling" -version = "0.24.0" +name = "egui-winit" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +checksum = "9327fc8edef2c57db9bcbcacc82c8a4b1e8cc64cd41a67db4419dcf643d88c83" dependencies = [ - "darling_core", - "darling_macro", + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "winit", ] [[package]] -name = "darling_core" -version = "0.24.0" +name = "egui_extras" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +checksum = "1e651e08ddf6bb8bfd964ad70e33bc721c093f2b77ab59580e7140c008dab68c" dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.3", + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "mime_guess2", + "profiling", ] [[package]] -name = "darling_macro" -version = "0.24.0" +name = "egui_glow" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +checksum = "69b65e45f8d8d7c6d848b3ec41f642b54ad828033f1511d4b4c9adc09040166a" dependencies = [ - "darling_core", - "quote", - "syn 3.0.3", + "bytemuck", + "egui", + "glow", + "log", + "profiling", ] [[package]] -name = "deltae" -version = "0.3.2" +name = "egui_kittest" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +checksum = "6a9ea43248738d0396342e7b537b3a79568b2a5d16e8c3fdfc0d8bcbd89dbf6d" +dependencies = [ + "egui", + "kittest", + "log", + "serde", + "toml 1.1.5+spec-1.1.0", +] [[package]] -name = "deranged" -version = "0.5.8" +name = "either" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] -name = "derive_more" -version = "2.1.1" +name = "emath" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "dce03cf1604f74515830012c29991beb0b58f69e4e43f5afe22fa7afed65ccd5" dependencies = [ - "derive_more-impl", + "bytemuck", + "serde", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", + "enum-map-derive", ] [[package]] -name = "digest" -version = "0.10.7" +name = "enum-map-derive" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "digest" -version = "0.11.3" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", + "enumflags2_derive", + "serde", ] [[package]] -name = "dirs" -version = "6.0.0" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ - "dirs-sys", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "enumn" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "epaint" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "11863a6b2b7823010c1090ddf4706947bdda46766742def0673195cbf7ff4a73" dependencies = [ - "litrs", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", ] [[package]] -name = "either" -version = "1.17.0" +name = "epaint_default_fonts" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "18dee69613aac468922cf28a32025eb7d7ed6985b61f73245848e58f37876c98" [[package]] name = "equivalent" @@ -545,9 +1150,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -558,94 +1169,181 @@ dependencies = [ ] [[package]] -name = "fancy-regex" -version = "0.11.0" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "bit-set", - "regex", + "parking", + "pin-project-lite", ] [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", + "event-listener", + "pin-project-lite", ] [[package]] -name = "find-msvc-tools" -version = "0.1.11" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "finl_unicode" -version = "1.4.0" +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] -name = "fixedbitset" -version = "0.4.2" +name = "fdeflate" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] [[package]] -name = "fnv" -version = "1.0.7" +name = "fearless_simd" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] -name = "foldhash" -version = "0.2.0" +name = "find-msvc-tools" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] -name = "futures-core" -version = "0.3.34" +name = "flate2" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] [[package]] -name = "futures-sink" -version = "0.3.34" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "futures-task" -version = "0.3.34" +name = "font-types" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", + "serde", +] [[package]] -name = "futures-util" -version = "0.3.34" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", "slab", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "typenum", - "version_check", + "rustix 1.1.4", + "windows-link", ] [[package]] @@ -660,377 +1358,891 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "scopeguard", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "log" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "hashbrown" -version = "0.16.1" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "hashbrown" -version = "0.17.1" +name = "memmap2" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "libc", ] [[package]] -name = "heck" -version = "0.5.0" +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] -name = "hex" -version = "0.4.3" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "hybrid-array" -version = "0.4.14" +name = "mime_guess2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" dependencies = [ - "typenum", + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "adler2", + "simd-adler32", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ - "cc", + "adler2", + "simd-adler32", ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "mio" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] [[package]] -name = "indoc" -version = "2.0.7" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "rustversion", + "num-traits", + "pxfm", ] [[package]] -name = "instability" -version = "0.3.13" +name = "ndk" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 3.0.3", + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "ndk-context" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] -name = "itertools" -version = "0.14.0" +name = "ndk-sys" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "either", + "jni-sys 0.3.1", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] -name = "js-sys" -version = "0.3.104" +name = "ntapi" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "winapi", ] [[package]] -name = "kasuari" -version = "0.4.12" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.20", + "autocfg", ] [[package]] -name = "lab" -version = "0.11.0" +name = "num_enum" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "num_enum_derive" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "libc" -version = "0.2.189" +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] -name = "libm" -version = "0.2.16" +name = "objc2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] [[package]] -name = "libredox" -version = "0.1.19" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "libc", + "objc2-encode", ] [[package]] -name = "line-clipping" -version = "0.3.8" +name = "objc2-app-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litrs" -version = "1.0.0" +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "objc2-cloud-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "scopeguard", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] -name = "log" -version = "0.4.33" +name = "objc2-contacts" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "lru" -version = "0.18.2" +name = "objc2-core-data" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "hashbrown 0.17.1", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "mac_address" -version = "1.1.8" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "nix", - "winapi", + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] [[package]] -name = "memmem" -version = "0.1.1" +name = "objc2-core-image" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] [[package]] -name = "memoffset" -version = "0.9.1" +name = "objc2-core-location" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "autocfg", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "mio" -version = "1.2.2" +name = "objc2-foundation" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", "libc", - "log", - "wasi", - "windows-sys", + "objc2 0.5.2", ] [[package]] -name = "nix" -version = "0.29.0" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] -name = "nom" -version = "7.1.3" +name = "objc2-io-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "memchr", - "minimal-lexical", + "libc", + "objc2-core-foundation", ] [[package]] -name = "ntapi" -version = "0.4.3" +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "winapi", + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] -name = "num-conv" +name = "objc2-link-presentation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "num-derive" -version = "0.4.2" +name = "objc2-metal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-quartz-core" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "autocfg", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "objc2-symbols" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "libc", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-core-foundation" +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-io-kit" -version = "0.3.2" +name = "objc2-uniform-type-identifiers" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "libc", - "objc2-core-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -1049,49 +2261,33 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "palette" -version = "0.7.7" +name = "orbclient" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ - "approx", - "libm", - "palette_derive", - "palette_math", + "libc", + "libredox", ] [[package]] -name = "palette_derive" -version = "0.7.7" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.119", + "futures-core", + "pin-project-lite", ] [[package]] -name = "palette_math" -version = "0.7.7" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" -dependencies = [ - "libm", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -1111,91 +2307,93 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] [[package]] -name = "pest" -version = "2.9.0" +name = "peniko" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ - "memchr", - "ucd-trie", + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", ] [[package]] -name = "pest_derive" -version = "2.9.0" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" -dependencies = [ - "pest", - "pest_generator", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pest_generator" -version = "2.9.0" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.119", + "phf_macros 0.11.3", + "phf_shared 0.11.3", ] [[package]] -name = "pest_meta" -version = "2.9.0" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "pest", + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] -name = "phf" +name = "phf_generator" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_macros", - "phf_shared", + "phf_shared 0.11.3", + "rand", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "phf_generator" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "phf_generator", - "phf_shared", + "fastrand", + "phf_shared 0.13.1", ] [[package]] -name = "phf_generator" +name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_shared", - "rand", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", + "unicase", ] [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", @@ -1208,6 +2406,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1217,16 +2445,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "portable-atomic" -version = "1.15.0" +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] [[package]] name = "proc-macro2" @@ -1237,6 +2521,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -1260,9 +2571,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_core", ] @@ -1274,111 +2585,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] -name = "ratatui" -version = "0.30.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termina", - "ratatui-termwiz", - "ratatui-widgets", - "serde", -] - -[[package]] -name = "ratatui-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" -dependencies = [ - "bitflags 2.13.1", - "compact_str", - "critical-section", - "hashbrown 0.17.1", - "itertools", - "kasuari", - "lru", - "palette", - "serde", - "strum", - "thiserror 2.0.20", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.2" +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "ratatui-termina" -version = "0.1.0" +name = "read-fonts" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ - "instability", - "ratatui-core", - "termina", + "bytemuck", + "font-types", + "once_cell", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.2" +name = "redox_syscall" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "ratatui-core", - "termwiz", + "bitflags 1.3.2", ] [[package]] -name = "ratatui-widgets" -version = "0.3.2" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags 2.13.1", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ "bitflags 2.13.1", ] @@ -1423,6 +2668,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1432,6 +2691,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1441,8 +2713,8 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -1452,10 +2724,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "ryu" -version = "1.0.23" +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scopeguard" @@ -1463,6 +2744,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -1513,14 +2800,23 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" +name = "serde_repr" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", ] [[package]] @@ -1530,8 +2826,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -1541,53 +2837,148 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "signal-hook" -version = "0.3.18" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", - "signal-hook-registry", ] [[package]] -name = "signal-hook-mio" -version = "0.2.5" +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "libc", - "mio", - "signal-hook", + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "smithay-client-toolkit" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "errno", + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "siphasher" -version = "1.0.3" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] [[package]] -name = "slab" -version = "0.4.12" +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] [[package]] -name = "smallvec" -version = "1.15.2" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1596,7 +2987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1611,38 +3002,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -1680,79 +3039,16 @@ dependencies = [ ] [[package]] -name = "termina" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" -dependencies = [ - "bitflags 2.13.1", - "parking_lot", - "rustix", - "signal-hook", - "windows-sys", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "anyhow", - "base64", - "bitflags 2.13.1", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -1796,26 +3092,19 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.55" +name = "tiff" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", ] -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - [[package]] name = "tokio" version = "1.53.1" @@ -1830,7 +3119,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1866,6 +3155,87 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -1873,39 +3243,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "ucd-trie" -version = "0.1.7" +name = "uds_windows" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] -name = "unicode-segmentation" -version = "1.13.3" +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] -name = "unicode-truncate" -version = "2.0.1" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-width" -version = "0.2.0" +name = "unicode-segmentation" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "utf8parse" @@ -1915,16 +3285,42 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "atomic", - "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1932,12 +3328,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "vtparse" -version = "0.6.2" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "utf8parse", + "same-file", + "winapi-util", ] [[package]] @@ -1968,6 +3365,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -2001,76 +3408,165 @@ dependencies = [ ] [[package]] -name = "wezterm-bidi" -version = "0.2.3" +name = "wayland-backend" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ - "log", - "wezterm-dynamic", + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", ] [[package]] -name = "wezterm-blob-leases" -version = "0.1.1" +name = "wayland-client" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", ] [[package]] -name = "wezterm-color-types" +name = "wayland-csd-frame" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", ] [[package]] -name = "wezterm-dynamic" -version = "0.2.1" +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ + "dlib", "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wezterm-input-types" -version = "0.1.0" +name = "weezl" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "winapi" @@ -2088,6 +3584,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2195,6 +3700,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2204,6 +3736,39 @@ 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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -2213,31 +3778,188 @@ dependencies = [ "windows-link", ] +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winmedic" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", "chrono", "clap", "colored", - "crossterm", "dirs", - "ratatui", + "eframe", + "egui_extras", + "egui_kittest", "regex", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "sysinfo", "thiserror 2.0.20", "tokio", "tokio-util", - "windows-sys", + "windows-sys 0.61.2", "winreg", "winres", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.56.0" @@ -2245,7 +3967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2254,7 +3976,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] @@ -2263,8 +3985,259 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index dc23555..5b3095b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,18 @@ [package] name = "winmedic" -version = "0.3.4" +version = "0.4.0" edition = "2024" # Verified against the locked dependency tree: edition 2024 itself needs 1.85, -# but ratatui -> instability -> darling raises the floor to 1.88. CI enforces -# this exact version in the `msrv` job so the two can never drift apart. -rust-version = "1.88" +# but egui/eframe raises the floor to 1.95. CI enforces this exact version in +# the `msrv` job so the two can never drift apart. +rust-version = "1.95" build = "build.rs" authors = ["Ammar "] -description = "High-performance Windows Self-Healing & Diagnostic TUI in Rust" +description = "High-performance Windows Self-Healing & Diagnostic GUI in Rust" readme = "README.md" license = "MIT" -keywords = ["windows", "diagnostics", "repair", "tui", "ratatui"] -categories = ["command-line-utilities", "system-administration"] +keywords = ["windows", "diagnostics", "repair", "gui", "egui"] +categories = ["gui", "system-administration"] [lib] name = "winmedic" @@ -24,8 +24,6 @@ path = "src/main.rs" [dependencies] async-trait = "0.1" -ratatui = "0.30" -crossterm = "0.29" tokio = { version = "1.40", features = ["full"] } tokio-util = "0.7" clap = { version = "4.5", features = ["derive"] } @@ -43,8 +41,22 @@ dirs = "6.0" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", + "Win32_System_Console", ] } +# The desktop front end. Default features are off because most of them are for +# platforms WinMedic does not target: `wayland` and `x11` are Linux display +# servers, `web_screen_reader` is for the wasm build. What is left is the glow +# (OpenGL) renderer, the bundled fonts, AccessKit for screen readers, and +# `persistence` so the window comes back where the user left it. +eframe = { version = "0.36", default-features = false, features = [ + "accesskit", + "default_fonts", + "glow", + "persistence", +] } +egui_extras = "0.36" + [build-dependencies] winres = "0.1" @@ -53,3 +65,6 @@ opt-level = 3 lto = true codegen-units = 1 panic = "abort" + +[dev-dependencies] +egui_kittest = "0.36.1" diff --git a/README.md b/README.md index 5dc0b02..31f9a59 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# 🩺 WinMedic – Windows Self-Healing & Diagnostic TUI +# 🩺 WinMedic – Windows Self-Healing & Diagnostic GUI **A high-performance, modular Windows diagnostic and auto-repair utility written in 100% Rust.** @@ -8,9 +8,9 @@ [![CI](https://img.shields.io/github/actions/workflow/status/SecretLUL/WinMedic/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI)](https://github.com/SecretLUL/WinMedic/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) [![Platform](https://img.shields.io/badge/Platform-Windows%2010%20%2F%2011%20(x64)-0078D6.svg?style=for-the-badge&logo=windows)](https://www.microsoft.com/windows) -[![Ratatui](https://img.shields.io/badge/TUI-Ratatui%200.29-00D2FF.svg?style=for-the-badge)](https://ratatui.rs/) +[![egui](https://img.shields.io/badge/GUI-egui%200.36-00D2FF.svg?style=for-the-badge)](https://www.egui.rs/) [![Release](https://img.shields.io/github/v/release/SecretLUL/WinMedic?style=for-the-badge&label=Version&color=10B981)](https://github.com/SecretLUL/WinMedic/releases/latest) -[![MSRV](https://img.shields.io/badge/MSRV-1.88-B7410E.svg?style=for-the-badge&logo=rust)](https://www.rust-lang.org/) +[![MSRV](https://img.shields.io/badge/MSRV-1.95-B7410E.svg?style=for-the-badge&logo=rust)](https://www.rust-lang.org/)
@@ -22,14 +22,15 @@ ## 🌟 Overview -**WinMedic** is a state-of-the-art terminal application (TUI) designed to autonomously diagnose, categorize, and safely repair Windows operating system errors, performance bottlenecks, update stalls, and broken configurations. +**WinMedic** is a state-of-the-art native desktop application designed to autonomously diagnose, categorize, and safely repair Windows operating system errors, performance bottlenecks, update stalls, and broken configurations. -Unlike opaque one-click cleanup tools, **WinMedic** is built on five fundamental principles: +Unlike opaque one-click cleanup tools, **WinMedic** is built on six fundamental principles: 1. **Zero Runtime Dependencies**: Single, compact, portable native `.exe` binary without .NET, Python, or external runtime requirements. 2. **True Parallel Diagnostics**: All diagnostic modules execute concurrently via Tokio `JoinSet` for blazing-fast hardware and OS analysis. -3. **Safety First**: Automatic **Windows System Restore Points (VSS)** and **Registry Snapshots** are taken prior to any modification — and every snapshot can be rolled back from inside the app. +3. **Safety First**: Automatic **Windows System Restore Points (VSS)** (with a 3-minute creation window) and **Registry Snapshots** are taken prior to any modification — and every snapshot can be rolled back from inside the app. 4. **Full Transparency & Live Triage**: Every issue is explained with technical logs, severity levels, risk scores, and step-by-step fix previews. A **dry-run mode** shows planned steps without changing anything, with instant live search and severity filtering. -5. **Always Interruptible & Bounded**: Any running scan or repair can be aborted with `[Esc]` (or `Ctrl+C` headless) terminating child processes. Memory usage is bounded via a 2000-line ring-buffer. +5. **Scan State Persistence & Reboot Reconciliation**: The entire diagnostic state is saved to `%APPDATA%\WinMedic\last_scan.json`. When restarting, WinMedic immediately restores your open issues without re-scanning, detects system restarts, and automatically reconciles pending update reboots. +6. **Always Interruptible & Bounded**: Any running scan or repair can be aborted with `[Esc]` (or `Ctrl+C` headless) terminating child processes. Memory usage is bounded via a 2000-line ring-buffer. --- @@ -39,16 +40,25 @@ Unlike opaque one-click cleanup tools, **WinMedic** is built on five fundamental | :--- | :--- | :--- | | **🛡 System Integrity** | DISM Component Store corruption, SFC system file integrity, CBS logs, VSS shadow copy health | Runs `DISM /RestoreHealth`, `sfc /scannow`, repairs Volume Shadow Copy services | | **🔄 Windows Update & Services** | `wuauserv`, `bits`, `cryptsvc`, `trustedinstaller`, bloated `SoftwareDistribution\Download` cache, stuck reboot flags | Gracefully resets update queues, purges corrupted download caches, re-registers update DLLs | -| **🌐 Network & DNS** | DNS name resolution, gateway ping reachability, Winsock catalog integrity, rogue proxy settings | `ipconfig /flushdns`, `ipconfig /registerdns`, `netsh winsock reset`, `netsh int ip reset`, proxy cleanup | +| **🌐 Network & DNS** | DNS name resolution through the machine's *own* resolver (two independent names, never a pinned public server), gateway ping reachability, Winsock catalog integrity, rogue proxy settings | `ipconfig /flushdns`, `ipconfig /registerdns`, `netsh winsock reset`, `netsh int ip reset`, proxy cleanup — the DNS repair re-queries the resolver afterwards and fails honestly if names still do not resolve | | **📋 Event Log & Crash Analysis** | Critical/Error event bursts in last 24h, WHEA hardware error architecture logs, `%SystemRoot%\Minidump` BSOD crash dumps | Corrupted log channel cleanup, crash dump analysis, hardware diagnostic recommendations | | **💾 Storage & Filesystem** | Dirty Bit detection (`fsutil dirty query C:`), SMART drive health, `%TEMP%` & `C:\Windows\Temp` junk accumulation, bloated `IconCache.db` | Triggers online `chkdsk C: /scan`, cleans temp files, resets icon/thumbnail cache & restarts Explorer | | **⚡ Registry & Autostart** | Orphaned `Run`/`RunOnce` startup keys, broken User Startup folder shortcuts, broken COM/Shell extension keys | Backs up target registry keys to `.reg` and safely removes invalid startup entries | -| **🧹 System & Cache Cleaner** | WinSxS component store bloat (`DISM /AnalyzeComponentStore`), Delivery Optimization cache, Installer package cache, browser caches (Chrome, Edge, Firefox, Brave, Opera — all profiles), setup & CBS logs, WER crash archives, D3D shader & certificate caches, Recycle Bin, system temp | Runs `StartComponentCleanup`, purges the caches you select, and skips locked files instead of aborting the sweep | -| **📅 Scheduled Tasks** | Tasks whose action points at a deleted program (`Get-ScheduledTask`), tasks whose last run failed with a real error code rather than a `SCHED_S_*` status, tasks with missed runs | Disables the task with `Disable-ScheduledTask` — reversible with `Enable-ScheduledTask`; nothing is deleted | +| **🧹 System & Cache Cleaner** | WinSxS component store bloat (`DISM /AnalyzeComponentStore`), Delivery Optimization cache, Installer package cache, browser caches (Chrome, Edge, Brave, every installed Opera flavour incl. GX, Firefox — all profiles), setup & CBS logs, WER crash archives, D3D shader & certificate caches, Recycle Bin, system temp | Runs `StartComponentCleanup`, purges the caches you select, and skips locked files instead of aborting the sweep. Only raises a finding once a target actually holds something worth reclaiming (10 MB, 50 MB for browser caches), so a directory Windows has begun refilling is not reported as an unfixed issue | +| **📅 Scheduled Tasks** | Tasks whose action points at a deleted program (`Get-ScheduledTask`), tasks whose last run failed with a real error code rather than a `SCHED_S_*` status, tasks with missed runs — tasks that are already disabled are skipped, since they fire on no trigger | Disables the task with `Disable-ScheduledTask`, with dynamic task resolution and automated ACL escalation (`takeown` / `icacls`) for protected system tasks — reversible with `Enable-ScheduledTask`; nothing is deleted. The task's state is read back afterwards, so a disable Windows accepts without applying is reported as a failure rather than as a repair | | **🧠 Page File & Memory** | Page file disabled with automatic management off (`Win32_PageFileUsage`), page file on a volume with under 10 % or 2 GB free, manually fixed limits below RAM/8 or with an inverted min/max range | Hands the page file back to Windows (system managed) or re-enables automatic management; the nearly-full-drive finding is advisory and changes nothing | +| **🩺 WHEA Hardware Logger** | Windows Hardware Error Architecture (WHEA) physical faults: CPU Cache Hierarchy (Event 19), Fatal Machine Checks (Event 18), PCIe Root Port bus errors (Event 17), RAM/Memory controller parity errors (Event 47), Storage CPER records (Event 1) | Automatic PCIe ASPM power management optimization (`powercfg`) to prevent bus dropouts, schedules Windows Memory Diagnostic (`mdsched.exe`), provides exact hardware core/APIC-ID and bus:device:function triangulation and BIOS tuning guidance | Package Cache and Recycle Bin are classified `RiskScore::High` and are **deselected by default**, so `--auto-fix` never empties them unattended. Every Page File & Memory finding is `RiskScore::High` for the same reason — each one needs a restart before it takes effect — and is likewise deselected. A scheduled task that merely *fails*, rather than pointing at a deleted program, is deselected too: switching it off is a judgement call, so it is left for you to tick. +### A repaired finding stays repaired + +A scan must not re-raise what a repair has already dealt with, and must not raise anything a repair could never clear. Three rules enforce that: + +- **A disabled scheduled task is not a finding.** Disabling is the only thing the repair does, and Windows never resets a task's `LastTaskResult` or restores its deleted program — so a task the scan reported again after it had been switched off could never be cleared, no matter how often you repaired it. +- **Cleanup targets have a floor.** Every directory the cleaner sweeps is one the system refills by itself: a service writes its next log line, Explorer rewrites the Recycle Bin's `desktop.ini` shell stub, a browser caches the next favicon. Below the floor there is nothing to decide, so nothing is reported. +- **DNS is tested through your resolver, and verified after the repair.** The check never pins a public DNS server, because networks that block outbound port 53 would otherwise produce a permanent critical finding that `ipconfig /flushdns` cannot possibly fix. After the repair the resolver is asked again, and the fix fails with the reason if names still do not resolve. + --- ## 🛡️ Safety & Backup Architecture @@ -57,7 +67,7 @@ Before WinMedic touches your system: 1. **Windows System Restore Point (VSS)**: A checkpoint named `"WinMedic Auto-Restore Point (Vor Reparatur)"` is automatically triggered via WMI / PowerShell. WinMedic then **verifies** that a new restore point actually appeared instead of trusting the exit status — Windows silently declines to create one if another was made within the last 24 hours (`SystemRestorePointCreationFrequency`), and reports that refusal as a warning rather than an error. A throttled run is surfaced as a warning, never as success. 2. **Registry Snapshotting**: Every modified registry key is exported into `%APPDATA%\WinMedic\backups\reg_.reg` prior to modification. If the export fails, the fix is aborted instead of applied. The backup index is written atomically, and an index that cannot be parsed is moved aside as `index.json.corrupt-` rather than overwritten, so previously recorded backups are never lost. 3. **One-Key Rollback**: Any stored snapshot can be restored directly from the **`[5]` Settings & Safety** tab — `[B]` moves the arrow keys onto the snapshot list, `[U]` restores the highlighted one after an explicit confirmation prompt. -4. **Dry-Run First**: `[D]` in the TUI or `--dry-run` on the CLI lists every command a repair would execute, without executing any of it. +4. **Dry-Run First**: the simulation switch in the window or `--dry-run` on the CLI lists every command a repair would execute, without executing any of it. 5. **High-Performance Audit Logging**: Every scan, fix, simulation, rollback, and cancellation is appended in $O(1)$ to `%APPDATA%\WinMedic\logs\history.jsonl` (with automatic 5 MB log rotation) and formatted human-readable `%APPDATA%\WinMedic\logs\audit.log`. 6. **Self-Contained Report Export**: Complete diagnostic findings can be exported at any time with `[E]` or `--output ` as responsive, standalone HTML, Markdown, or JSON reports. @@ -111,7 +121,7 @@ If you installed WinMedic through WinGet, prefer `winget upgrade SecretLUL.WinMe | Shortcut | Action | | :--- | :--- | | **`[1]` - `[5]`** | Switch tabs (Dashboard, Health Scan, Issue Triage, Repair Center, Settings & Safety) | -| **`[Tab]` / `[Shift+Tab]`** | Cycle forward / backward through tabs | +| **`[Ctrl+Tab]` / `[Ctrl+Shift+Tab]`** | Cycle forward / backward through tabs (plain `[Tab]` moves focus between controls) | | **`[S]`** | Start full system health scan | | **`[R]`** | Re-run scan / refresh current view | | **`[Space]`** | Toggle checkbox selection for highlighted issue (toggles a switch in Settings) | @@ -119,18 +129,17 @@ If you installed WinMedic through WinGet, prefer `winget upgrade SecretLUL.WinMe | **`[m]`** | Filter issues by diagnostic module (cycle through modules) in Triage tab | | **`[/]`** | Fulltext live search across findings, details & descriptions | | **`[x]`** | Reset all active filters and search queries | -| **`[A]`** | Select all visible detected issues (1-Click Auto-Fix) | +| **`[A]`** | Toggle select / deselect all visible detected issues (1-Click Auto-Fix) | | **`[N]`** | Deselect all issues | | **`[F]`** | Proceed to Repair Center / Execute repairs | | **`[D]`** | Toggle dry-run mode — repairs are shown, not executed | | **`[E]`** | Export diagnostic & repair report as self-contained HTML | | **`[B]`** | Settings & Safety tab: move `[↑]`/`[↓]` between the settings list and the registry snapshot list | | **`[U]`** | Settings & Safety tab: restore the selected registry snapshot — elsewhere: open the pending "update available" notice, which can download, verify and install the new version | -| **`[PgUp]` / `[PgDn]`** | Scroll live log console (Scan and Repair tabs) | -| **`[Home]` / `[End]`** | Jump to earliest log line / return to live tail follow mode | +| **`[PgUp]` / `[PgDn]` / `[Home]` / `[End]`** | Move the Triage selection by a page, or to the first / last issue (the live log consoles scroll with the mouse wheel and their own scrollbars) | | **`[←]` / `[→]` or `[h]` / `[l]`** | Switch tabs (BIOS-style, wraps around) | | **`[+]` / `[-]` or `[[` / `]]`** | Adjust the highlighted numeric setting (Settings & Safety tab) | -| **`[↑]` / `[↓]` or `[j]` / `[k]`** | Navigate list items and scroll logs | +| **`[↑]` / `[↓]` or `[j]` / `[k]`** | Navigate list items (the live log consoles scroll with the mouse wheel and their own scrollbars) | | **`[?]`** | Open interactive Help Modal overlay | | **`[Esc]`** | Clear filters / abort a running operation / close modal / return to Dashboard | | **`[Q]`** | Exit WinMedic safely | @@ -139,7 +148,7 @@ If you installed WinMedic through WinGet, prefer `winget upgrade SecretLUL.WinMe ## 🚀 CLI Headless Automation Mode -WinMedic can also run without the TUI for automated scripts, CI/CD, or batch IT deployments: +WinMedic can also run without opening its window, for automated scripts, CI/CD, or batch IT deployments. Every flag below keeps the console it was started from, so exit codes, pipes and redirects behave exactly as a script expects: ```bash # Run headless system scan and output styled summary @@ -219,8 +228,8 @@ WinMedic is **not code-signed**, so Windows SmartScreen will warn you on first l ```powershell # Compare the published checksum against the file you downloaded -$expected = (Get-Content .\winmedic-v0.3.4.exe.sha256).Split(' ')[0] -$actual = (Get-FileHash .\winmedic-v0.3.4.exe -Algorithm SHA256).Hash.ToLower() +$expected = (Get-Content .\winmedic-v0.4.0.exe.sha256).Split(' ')[0] +$actual = (Get-FileHash .\winmedic-v0.4.0.exe -Algorithm SHA256).Hash.ToLower() if ($expected -eq $actual) { "OK - checksum matches" } else { "MISMATCH - do not run this file" } ``` @@ -232,7 +241,8 @@ The checksum is generated by the release workflow from the exact binary it publi ### Prerequisites * **Windows 10 / 11** (64-bit) -* **Rust 1.88+** (`cargo` and `rustc`) — the MSRV is declared as `rust-version` in `Cargo.toml` and enforced by CI +* **Rust 1.95+** (`cargo` and `rustc`) — the MSRV is declared as `rust-version` in `Cargo.toml` and enforced by CI +* **MSVC build tools** — the Windows SDK supplies the `rc.exe` that `build.rs` uses to embed the icon and version resources ### Build Steps diff --git a/WinMedic_Empfohlene_Diagnose_Module_2026.pdf b/WinMedic_Empfohlene_Diagnose_Module_2026.pdf new file mode 100644 index 0000000..d1c21c3 Binary files /dev/null and b/WinMedic_Empfohlene_Diagnose_Module_2026.pdf differ diff --git a/assets/banner.jpg b/assets/banner.jpg index 663c6ef..d7e66c9 100644 Binary files a/assets/banner.jpg and b/assets/banner.jpg differ diff --git a/build.rs b/build.rs index ff45749..b3d92c8 100644 --- a/build.rs +++ b/build.rs @@ -8,7 +8,7 @@ fn main() { res.set("ProductName", "WinMedic"); res.set( "FileDescription", - "WinMedic – Windows Self-Healing & Diagnostic TUI", + "WinMedic – Windows Self-Healing & Diagnostic GUI", ); res.set("CompanyName", "SecretLUL"); res.set("LegalCopyright", "Copyright (c) 2026 SecretLUL"); diff --git a/src/app/confirm.rs b/src/app/confirm.rs index f61aaa1..b4ebbe5 100644 --- a/src/app/confirm.rs +++ b/src/app/confirm.rs @@ -29,8 +29,8 @@ const UPDATE_TRANSFER_TIMEOUT: std::time::Duration = std::time::Duration::from_s /// /// [`Default`] is deliberately the *inert* set, so an [`App`] built anywhere — /// a test, a future tool, a benchmark — cannot reach the desktop by accident. -/// The real actions are installed once, explicitly, by the TUI entry point via -/// [`App::enable_real_system_actions`]. +/// The real actions are installed once, explicitly, by the desktop front end +/// via [`App::enable_real_system_actions`]. #[derive(Debug, Clone, Copy)] pub struct SystemActions { /// Hand a release URL to the OS so it opens in the default browser. @@ -47,17 +47,34 @@ pub struct SystemActions { /// [`App::enable_real_system_actions`] exists instead of a plain /// assignment. pub restore_point: RestorePointService, + /// Reboot the machine to finalize repairs that require a system restart. + pub restart_system: fn() -> Result<(), String>, +} + +fn real_restart_system() -> Result<(), String> { + std::process::Command::new("shutdown.exe") + .args([ + "/r", + "/t", + "0", + "/c", + "WinMedic: Restarting to apply system repairs", + ]) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Could not initiate restart: {}", e)) } impl SystemActions { /// The real thing: opens a browser window, raises a UAC prompt, runs - /// `Checkpoint-Computer`. + /// `Checkpoint-Computer`, reboots the system. pub fn real() -> Self { Self { open_release_page: updater::launch_browser, relaunch_elevated: relaunch_as_admin, self_update: SelfUpdateService::real(), restore_point: RestorePointService::real(), + restart_system: real_restart_system, } } @@ -68,6 +85,7 @@ impl SystemActions { relaunch_elevated: || Ok(()), self_update: SelfUpdateService::inert(), restore_point: RestorePointService::inert(), + restart_system: || Ok(()), } } } @@ -98,6 +116,9 @@ pub enum ConfirmRequest { /// cannot verify is never installed in place. download: Option, }, + RestartRequired { + issues: Vec, + }, } impl ConfirmRequest { @@ -106,6 +127,7 @@ impl ConfirmRequest { ConfirmRequest::Rollback { .. } => "RESTORE REGISTRY BACKUP?", ConfirmRequest::Elevate => "ADMINISTRATOR PRIVILEGES REQUIRED", ConfirmRequest::UpdateAvailable { .. } => "NEW WINMEDIC UPDATE AVAILABLE", + ConfirmRequest::RestartRequired { .. } => "SYSTEM RESTART REQUIRED", } } @@ -119,6 +141,7 @@ impl ConfirmRequest { ConfirmRequest::UpdateAvailable { download: None, .. } => { "Open the release page in a browser" } + ConfirmRequest::RestartRequired { .. } => "Restart now", } } @@ -127,6 +150,7 @@ impl ConfirmRequest { ConfirmRequest::Rollback { .. } => "Cancel", ConfirmRequest::Elevate => "Continue without Administrator", ConfirmRequest::UpdateAvailable { .. } => "Remind me later", + ConfirmRequest::RestartRequired { .. } => "Later", } } @@ -200,6 +224,19 @@ impl ConfirmRequest { body } + ConfirmRequest::RestartRequired { issues } => { + let mut body = vec![ + "One or more applied repairs require a system restart to take effect:" + .to_string(), + String::new(), + ]; + for issue_title in issues { + body.push(format!(" • {}", issue_title)); + } + body.push(String::new()); + body.push("Restart the system now to complete these repairs?".to_string()); + body + } } } } @@ -223,6 +260,12 @@ impl App { self.status_message = Some("Update notice dismissed - [U] reopens it.".to_string()); } + ConfirmRequest::RestartRequired { .. } => { + self.status_message = Some( + "Restart postponed. A system restart is pending to finalize repairs." + .to_string(), + ); + } } } } @@ -274,7 +317,7 @@ impl App { /// /// Returns immediately: the work runs on the Tokio runtime and reports back /// through [`BackgroundEvent::UpdateInstallStep`] and - /// [`BackgroundEvent::UpdateInstallFinished`], so the TUI keeps drawing + /// [`BackgroundEvent::UpdateInstallFinished`], so the window keeps drawing /// while a multi-megabyte download is in flight. fn start_update_install( &mut self, @@ -376,6 +419,13 @@ impl App { self.available_update = None; } }, + ConfirmRequest::RestartRequired { .. } => { + if let Err(e) = (self.system_actions.restart_system)() { + self.status_message = Some(format!("System restart failed: {}", e)); + } else { + self.should_quit = true; + } + } } } } @@ -387,7 +437,7 @@ mod tests { /// A default-built [`App`] must not be able to reach the desktop. Accepting /// a dialog used to open a browser window and raise a UAC prompt on whoever /// ran the suite — several times per run, since more than one test accepts - /// one. The real actions are opt-in, and only the TUI opts in. + /// one. The real actions are opt-in, and only the desktop front end opts in. #[test] fn a_default_app_cannot_reach_the_desktop() { let actions = App::new().system_actions; @@ -624,4 +674,52 @@ mod tests { assert!(body.contains("v0.2.0")); assert!(body.contains("https://github.com/SecretLUL/WinMedic/releases/tag/v0.2.0")); } + + #[test] + fn test_confirm_request_restart_required() { + let req = ConfirmRequest::RestartRequired { + issues: vec![ + "System reboot pending after updates".to_string(), + "Page file disabled on every drive".to_string(), + ], + }; + assert_eq!(req.title(), "SYSTEM RESTART REQUIRED"); + assert_eq!(req.confirm_label(), "Restart now"); + assert_eq!(req.dismiss_label(), "Later"); + let body = req.body().join("\n"); + assert!(body.contains("System reboot pending after updates")); + assert!(body.contains("Page file disabled on every drive")); + assert!(body.contains("Restart the system now")); + } + + #[test] + fn test_confirm_restart_action_in_inert_mode() { + let mut app = App::new(); + app.pending_confirm = Some(ConfirmRequest::RestartRequired { + issues: vec!["System reboot pending after updates".to_string()], + }); + + app.confirm_pending_action(); + + assert!(app.pending_confirm.is_none()); + assert!(app.should_quit); + } + + #[test] + fn test_dismiss_restart_action_leaves_status_message() { + let mut app = App::new(); + app.pending_confirm = Some(ConfirmRequest::RestartRequired { + issues: vec!["System reboot pending after updates".to_string()], + }); + + app.dismiss_confirm(); + + assert!(app.pending_confirm.is_none()); + assert!(!app.should_quit); + assert!( + app.status_message + .as_deref() + .is_some_and(|m| m.contains("Restart postponed")) + ); + } } diff --git a/src/app/events.rs b/src/app/events.rs index 2b30960..124815f 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -1,9 +1,11 @@ //! Draining the scan, repair and background channels into application state. //! -//! Called once per frame from the TUI loop. Everything here is non-blocking: -//! `try_recv` until empty, never awaiting, so a slow producer cannot stall -//! rendering. +//! Called once per frame from the front end, out of `eframe::App::logic` — +//! which runs even while the window is hidden, so a scan does not appear to +//! stall when it is minimised. Everything here is non-blocking: `try_recv` +//! until empty, never awaiting, so a slow producer cannot stall rendering. +use super::ConfirmRequest; use super::state::{App, ModuleScanProgress}; use super::{BackgroundEvent, push_bounded_log}; use crate::engine::issue::Severity; @@ -175,6 +177,7 @@ impl App { self.scan_event_rx = None; self.cancel_token = None; self.audit_entries = self.audit_logger.get_history(); + self.save_scan_state(); } } @@ -234,8 +237,19 @@ impl App { if !self.dry_run && let Some(issue) = self.issues.iter_mut().find(|i| i.id == issue_id) { - issue.is_fixed = success; - if !success { + if success { + if issue.requires_reboot { + issue.is_reboot_pending = true; + issue.is_fixed = false; + issue.is_selected = false; + } else { + issue.is_fixed = true; + issue.is_reboot_pending = false; + } + issue.fix_error = None; + } else { + issue.is_fixed = false; + issue.is_reboot_pending = false; issue.fix_error = Some(message.clone()); } } @@ -290,6 +304,20 @@ impl App { fixed_count, failed_count ) }); + + if !self.dry_run { + let reboot_issues: Vec = self + .issues + .iter() + .filter(|i| i.is_reboot_pending) + .map(|i| i.title.clone()) + .collect(); + if !reboot_issues.is_empty() { + self.pending_confirm = Some(ConfirmRequest::RestartRequired { + issues: reboot_issues, + }); + } + } } } } @@ -300,6 +328,7 @@ impl App { self.audit_entries = self.audit_logger.get_history(); self.backup_records = self.reg_backup_mgr.list_backups(); self.clamp_backup_selection(); + self.save_scan_state(); } } @@ -692,4 +721,57 @@ mod tests { std::thread::sleep(Duration::from_millis(20)); assert_eq!(app.scan_elapsed(), Some(frozen), "and it stays put"); } + + #[tokio::test] + async fn repairs_requiring_reboot_trigger_restart_confirmation() { + let mut app = App::new(); + app.issues.clear(); + let issue = crate::engine::issue::Issue::new( + "wu_reboot_pending", + "windows_updates", + "System reboot pending after updates", + "Windows Update & Services", + crate::engine::issue::Severity::Info, + crate::engine::issue::RiskScore::Low, + "Description", + "Details", + "Fix", + vec!["Step 1".to_string()], + ) + .with_requires_reboot(true); + + app.issues.push(issue); + let (tx, rx) = channel::(10); + app.repair_event_rx = Some(rx); + app.is_fixing = true; + + tx.send(RepairEvent::FixFinished { + issue_id: "wu_reboot_pending".to_string(), + success: true, + message: "Pending reboot recorded.".to_string(), + }) + .await + .unwrap(); + + tx.send(RepairEvent::AllRepairsCompleted { + fixed_count: 1, + failed_count: 0, + }) + .await + .unwrap(); + + app.process_background_events(); + + assert!(!app.is_fixing); + assert!(app.issues[0].is_reboot_pending); + assert!(!app.issues[0].is_fixed); + assert!(app.has_pending_reboot()); + match app.pending_confirm { + Some(ConfirmRequest::RestartRequired { ref issues }) => { + assert_eq!(issues.len(), 1); + assert_eq!(issues[0], "System reboot pending after updates"); + } + other => panic!("expected RestartRequired confirm request, got {:?}", other), + } + } } diff --git a/src/app/filters.rs b/src/app/filters.rs index 591e8ef..42127e6 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -63,20 +63,45 @@ impl App { if let Some(&orig_idx) = indices.get(self.selected_filtered_index) && let Some(issue) = self.issues.get_mut(orig_idx) && !issue.is_fixed + && !issue.is_reboot_pending { issue.is_selected = !issue.is_selected; + self.save_scan_state(); } } + pub fn toggle_select_all_issues(&mut self) { + let indices = self.filtered_issue_indices(); + let any_unselected = indices.iter().any(|&idx| { + if let Some(issue) = self.issues.get(idx) { + !issue.is_fixed && !issue.is_reboot_pending && !issue.is_selected + } else { + false + } + }); + + for &orig_idx in &indices { + if let Some(issue) = self.issues.get_mut(orig_idx) + && !issue.is_fixed + && !issue.is_reboot_pending + { + issue.is_selected = any_unselected; + } + } + self.save_scan_state(); + } + pub fn select_all_issues(&mut self) { let indices = self.filtered_issue_indices(); for &orig_idx in &indices { if let Some(issue) = self.issues.get_mut(orig_idx) && !issue.is_fixed + && !issue.is_reboot_pending { issue.is_selected = true; } } + self.save_scan_state(); } pub fn deselect_all_issues(&mut self) { @@ -86,6 +111,7 @@ impl App { issue.is_selected = false; } } + self.save_scan_state(); } pub fn next_issue(&mut self) { @@ -106,6 +132,35 @@ impl App { } } + pub fn first_issue(&mut self) { + let indices = self.filtered_issue_indices(); + if !indices.is_empty() { + self.selected_filtered_index = 0; + } + } + + pub fn last_issue(&mut self) { + let indices = self.filtered_issue_indices(); + if !indices.is_empty() { + self.selected_filtered_index = indices.len() - 1; + } + } + + pub fn page_up_issue(&mut self, step: usize) { + let indices = self.filtered_issue_indices(); + if !indices.is_empty() { + self.selected_filtered_index = self.selected_filtered_index.saturating_sub(step); + } + } + + pub fn page_down_issue(&mut self, step: usize) { + let indices = self.filtered_issue_indices(); + if !indices.is_empty() { + self.selected_filtered_index = + (self.selected_filtered_index + step).min(indices.len() - 1); + } + } + pub fn toggle_severity_filter(&mut self, sev: Severity) { if self.severity_filter == Some(sev) { self.severity_filter = None; @@ -150,7 +205,7 @@ impl App { self.severity_filter = None; self.module_filter = None; self.search_query.clear(); - self.is_searching = false; + self.focus_search = false; self.clamp_filtered_selection(); } @@ -349,4 +404,28 @@ mod tests { app.clear_filters(); assert!(!app.has_active_filters()); } + + #[test] + fn paging_and_boundary_jumps() { + let mut app = app_with_issues(); + assert_eq!(app.selected_filtered_index, 0); + + app.last_issue(); + assert_eq!(app.selected_filtered_index, 2); + + app.first_issue(); + assert_eq!(app.selected_filtered_index, 0); + + app.page_down_issue(1); + assert_eq!(app.selected_filtered_index, 1); + + app.page_down_issue(10); + assert_eq!(app.selected_filtered_index, 2); + + app.page_up_issue(1); + assert_eq!(app.selected_filtered_index, 1); + + app.page_up_issue(10); + assert_eq!(app.selected_filtered_index, 0); + } } diff --git a/src/app/input.rs b/src/app/input.rs index be5f17b..5c187a6 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -6,24 +6,67 @@ //! moved somewhere it can be exercised. //! //! Dispatch is layered, and the order matters: a pending confirmation swallows -//! everything, then the help overlay, then search input capture, and only then -//! the normal bindings. +//! everything, then the help overlay, and only then the normal bindings. +//! +//! Text entry is deliberately absent from that list. It used to be a layer of +//! its own, because a terminal has no text fields and the search box and the +//! numeric setting editor had to be assembled a keystroke at a time. The window +//! has real fields bound to the same state, and while one of them holds focus +//! the front end sends nothing here at all. use super::state::App; use super::{TAB_DASHBOARD, TAB_REPAIR, TAB_SCANNER, TAB_SETTINGS, TAB_TRIAGE}; use crate::engine::issue::Severity; -use crossterm::event::KeyCode; -pub fn handle_key(app: &mut App, code: KeyCode) { +/// A keystroke, expressed independently of any UI toolkit. +/// +/// Dispatch used to take `crossterm::event::KeyCode` directly, which made this +/// module — the single largest piece of interaction logic in the crate — a +/// dependent of whichever library happened to be drawing the screen. It is the +/// only thing in `app` that ever was. Naming the keys ourselves keeps the +/// dispatch table and its tests intact across a change of front end; the front +/// end's job is to translate its own key events into these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Char(char), + Enter, + Esc, + Backspace, + Tab, + /// Shift+Tab. + BackTab, + Up, + Down, + Left, + Right, + PageUp, + PageDown, + Home, + End, + /// Everything the front end recognises but this table does not bind. + /// + /// Mapping the unbound keys onto one variant rather than dropping them at + /// the front end keeps "this key does nothing" a statement the dispatch + /// table makes, and therefore one a test can check. + Unbound, +} + +/// How far Page Up and Page Down move the triage selection. +/// +/// The terminal build derived this from the height of the visible list. A +/// window resizes freely, so a fixed step is both simpler and steadier: the +/// same keypress moves the same distance whatever the user has done to the +/// window. +const PAGE_STEP: usize = 10; + +pub fn handle_key(app: &mut App, code: Key) { // A pending confirmation swallows every other key. if app.pending_confirm.is_some() { match code { - KeyCode::Char('y') - | KeyCode::Char('Y') - | KeyCode::Char('j') - | KeyCode::Char('J') - | KeyCode::Enter => app.confirm_pending_action(), - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dismiss_confirm(), + Key::Char('y') | Key::Char('Y') | Key::Char('j') | Key::Char('J') | Key::Enter => { + app.confirm_pending_action() + } + Key::Char('n') | Key::Char('N') | Key::Esc => app.dismiss_confirm(), _ => {} } return; @@ -32,20 +75,20 @@ pub fn handle_key(app: &mut App, code: KeyCode) { // A pending setting numeric input modal captures typing until saved or cancelled. if let Some(input) = app.setting_input.as_mut() { match code { - KeyCode::Char(c) if c.is_ascii_digit() => { + Key::Char(c) if c.is_ascii_digit() => { if input.buffer.len() < 10 { input.buffer.push(c); input.error_msg = None; } } - KeyCode::Backspace => { + Key::Backspace => { input.buffer.pop(); input.error_msg = None; } - KeyCode::Enter => { + Key::Enter => { app.submit_setting_input(); } - KeyCode::Esc => { + Key::Esc => { app.cancel_setting_input(); } _ => {} @@ -55,7 +98,7 @@ pub fn handle_key(app: &mut App, code: KeyCode) { if app.show_help { match code { - KeyCode::Char('?') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + Key::Char('?') | Key::Esc | Key::Char('q') | Key::Char('Q') => { app.show_help = false; } _ => {} @@ -63,39 +106,21 @@ pub fn handle_key(app: &mut App, code: KeyCode) { return; } - if app.active_tab == TAB_TRIAGE && app.is_searching { - match code { - KeyCode::Esc | KeyCode::Enter => { - app.is_searching = false; - } - KeyCode::Backspace => { - app.search_query.pop(); - app.clamp_filtered_selection(); - } - KeyCode::Char(c) => { - app.search_query.push(c); - app.clamp_filtered_selection(); - } - _ => {} - } - return; - } - match code { - KeyCode::Char('q') | KeyCode::Char('Q') => app.should_quit = true, - KeyCode::Char('?') => app.show_help = true, + Key::Char('q') | Key::Char('Q') => app.should_quit = true, + Key::Char('?') => app.show_help = true, - KeyCode::Char('1') => app.goto_tab(TAB_DASHBOARD), - KeyCode::Char('2') => app.goto_tab(TAB_SCANNER), - KeyCode::Char('3') => app.goto_tab(TAB_TRIAGE), - KeyCode::Char('4') => app.goto_tab(TAB_REPAIR), - KeyCode::Char('5') => app.goto_tab(TAB_SETTINGS), + Key::Char('1') => app.goto_tab(TAB_DASHBOARD), + Key::Char('2') => app.goto_tab(TAB_SCANNER), + Key::Char('3') => app.goto_tab(TAB_TRIAGE), + Key::Char('4') => app.goto_tab(TAB_REPAIR), + Key::Char('5') => app.goto_tab(TAB_SETTINGS), - KeyCode::Tab => app.next_tab(), - KeyCode::BackTab => app.prev_tab(), + Key::Tab => app.next_tab(), + Key::BackTab => app.prev_tab(), - KeyCode::Char('s') | KeyCode::Char('S') => app.start_scan(), - KeyCode::Char('r') | KeyCode::Char('R') => { + Key::Char('s') | Key::Char('S') => app.start_scan(), + Key::Char('r') | Key::Char('R') => { if app.active_tab == TAB_SETTINGS { app.load_safety_data(); app.refresh_restore_points(); @@ -106,13 +131,13 @@ pub fn handle_key(app: &mut App, code: KeyCode) { // Hands the arrow keys to the backup list and back, so one tab can carry // both the settings list and the rollback target selection. - KeyCode::Char('b') | KeyCode::Char('B') if app.active_tab == TAB_SETTINGS => { + Key::Char('b') | Key::Char('B') if app.active_tab == TAB_SETTINGS => { app.toggle_safety_focus(); } - KeyCode::Char('d') | KeyCode::Char('D') => app.toggle_dry_run(), + Key::Char('d') | Key::Char('D') => app.toggle_dry_run(), - KeyCode::Char('f') | KeyCode::Char('F') => { + Key::Char('f') | Key::Char('F') => { if app.active_tab == TAB_TRIAGE || app.active_tab == TAB_REPAIR { app.start_repairs(); } else { @@ -120,16 +145,18 @@ pub fn handle_key(app: &mut App, code: KeyCode) { } } - KeyCode::Char('a') | KeyCode::Char('A') => { + Key::Char('a') | Key::Char('A') => { if app.active_tab == TAB_DASHBOARD { app.start_scan(); + } else if app.active_tab == TAB_TRIAGE { + app.toggle_select_all_issues(); } else { app.select_all_issues(); } } - KeyCode::Char('n') | KeyCode::Char('N') => app.deselect_all_issues(), + Key::Char('n') | Key::Char('N') => app.deselect_all_issues(), - KeyCode::Char('u') | KeyCode::Char('U') => { + Key::Char('u') | Key::Char('U') => { if app.active_tab == TAB_SETTINGS { app.request_rollback(); } else { @@ -138,7 +165,7 @@ pub fn handle_key(app: &mut App, code: KeyCode) { } } - KeyCode::Char('e') | KeyCode::Char('E') => match app.export_report() { + Key::Char('e') | Key::Char('E') => match app.export_report() { Ok(path) => { app.status_message = Some(format!("Report exported: {}", path.display())); } @@ -151,75 +178,68 @@ pub fn handle_key(app: &mut App, code: KeyCode) { // list holds focus — there is nothing on that side to toggle, and // silently editing the hidden selection would be worse than doing // nothing. - KeyCode::Enter => match app.active_tab { + Key::Enter => match app.active_tab { TAB_TRIAGE => app.toggle_selected_issue(), TAB_SETTINGS if !app.backups_focused() => app.open_setting_input(), _ => {} }, - KeyCode::Char(' ') => match app.active_tab { + Key::Char(' ') => match app.active_tab { TAB_TRIAGE => app.toggle_selected_issue(), TAB_SETTINGS if !app.backups_focused() => app.toggle_current_setting(), _ => {} }, - KeyCode::Up | KeyCode::Char('k') => match app.active_tab { + // The scan and repair logs used to be scrolled from here. They are + // scroll areas now, which the mouse wheel and their own scrollbars + // drive, so there is nothing left for these keys to move. + Key::Up | Key::Char('k') => match app.active_tab { TAB_TRIAGE => app.prev_issue(), TAB_SETTINGS if app.backups_focused() => app.prev_backup(), TAB_SETTINGS => app.prev_setting(), - TAB_SCANNER | TAB_REPAIR => app.scroll_log_up(1), _ => {} }, - KeyCode::Down | KeyCode::Char('j') => match app.active_tab { + Key::Down | Key::Char('j') => match app.active_tab { TAB_TRIAGE => app.next_issue(), TAB_SETTINGS if app.backups_focused() => app.next_backup(), TAB_SETTINGS => app.next_setting(), - TAB_SCANNER | TAB_REPAIR => app.scroll_log_down(1), - _ => {} - }, - KeyCode::PageUp => match app.active_tab { - TAB_SCANNER | TAB_REPAIR => app.scroll_log_up(10), - _ => {} - }, - KeyCode::PageDown => match app.active_tab { - TAB_SCANNER | TAB_REPAIR => app.scroll_log_down(10), - _ => {} - }, - KeyCode::Home => match app.active_tab { - TAB_SCANNER | TAB_REPAIR => app.scroll_log_top(), - _ => {} - }, - KeyCode::End => match app.active_tab { - TAB_SCANNER | TAB_REPAIR => app.scroll_log_bottom(), _ => {} }, - KeyCode::Char('/') if app.active_tab == TAB_TRIAGE => app.is_searching = true, - KeyCode::Char('c') | KeyCode::Char('C') if app.active_tab == TAB_TRIAGE => { + // Jumping through the triage list, on the other hand, is still ours. + // The list is a scroll area too, but the mouse only moves the viewport; + // these keys move the selection, which is what Enter and Space act on. + Key::PageUp if app.active_tab == TAB_TRIAGE => app.page_up_issue(PAGE_STEP), + Key::PageDown if app.active_tab == TAB_TRIAGE => app.page_down_issue(PAGE_STEP), + Key::Home if app.active_tab == TAB_TRIAGE => app.first_issue(), + Key::End if app.active_tab == TAB_TRIAGE => app.last_issue(), + + Key::Char('/') if app.active_tab == TAB_TRIAGE => app.focus_search = true, + Key::Char('c') | Key::Char('C') if app.active_tab == TAB_TRIAGE => { app.toggle_severity_filter(Severity::Critical); } - KeyCode::Char('w') | KeyCode::Char('W') if app.active_tab == TAB_TRIAGE => { + Key::Char('w') | Key::Char('W') if app.active_tab == TAB_TRIAGE => { app.toggle_severity_filter(Severity::Warning); } - KeyCode::Char('i') | KeyCode::Char('I') if app.active_tab == TAB_TRIAGE => { + Key::Char('i') | Key::Char('I') if app.active_tab == TAB_TRIAGE => { app.toggle_severity_filter(Severity::Info); } - KeyCode::Char('m') | KeyCode::Char('M') if app.active_tab == TAB_TRIAGE => { + Key::Char('m') | Key::Char('M') if app.active_tab == TAB_TRIAGE => { app.cycle_module_filter(); } - KeyCode::Char('x') | KeyCode::Char('X') if app.active_tab == TAB_TRIAGE => { + Key::Char('x') | Key::Char('X') if app.active_tab == TAB_TRIAGE => { app.clear_filters(); } - KeyCode::Left | KeyCode::Char('h') => app.prev_tab(), - KeyCode::Right | KeyCode::Char('l') => app.next_tab(), + Key::Left | Key::Char('h') => app.prev_tab(), + Key::Right | Key::Char('l') => app.next_tab(), - KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') + Key::Char('+') | Key::Char('=') | Key::Char(']') if app.active_tab == TAB_SETTINGS && !app.backups_focused() => { app.adjust_current_setting(true); } - KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') + Key::Char('-') | Key::Char('_') | Key::Char('[') if app.active_tab == TAB_SETTINGS && !app.backups_focused() => { app.adjust_current_setting(false); @@ -227,7 +247,7 @@ pub fn handle_key(app: &mut App, code: KeyCode) { // Esc unwinds one layer at a time: filters, then backup focus, then a // running operation, then the tab itself. - KeyCode::Esc => { + Key::Esc => { if app.active_tab == TAB_TRIAGE && app.has_active_filters() { app.clear_filters(); } else if app.active_tab == TAB_SETTINGS && app.backups_focused() { @@ -266,6 +286,7 @@ mod tests { fn app() -> App { let mut app = App::new(); app.pending_confirm = None; + app.issues.clear(); app } @@ -306,7 +327,7 @@ mod tests { app.pending_confirm = Some(ConfirmRequest::Elevate); // 'q' or other arbitrary keys would normally trigger actions, but must be swallowed without dismissing. - handle_key(&mut app, KeyCode::Char('q')); + handle_key(&mut app, Key::Char('q')); assert!(!app.should_quit, "the modal must absorb the keystroke"); assert!( app.pending_confirm.is_some(), @@ -314,7 +335,7 @@ mod tests { ); // 'n' or Esc dismisses the confirmation. - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert!(app.pending_confirm.is_none(), "Esc dismisses the modal"); } @@ -324,14 +345,14 @@ mod tests { // Dismiss via 'n' app.pending_confirm = Some(ConfirmRequest::Elevate); - handle_key(&mut app, KeyCode::Char(' ')); + handle_key(&mut app, Key::Char(' ')); assert!(app.pending_confirm.is_some(), "Space ignored"); - handle_key(&mut app, KeyCode::Char('n')); + handle_key(&mut app, Key::Char('n')); assert!(app.pending_confirm.is_none(), "'n' dismisses"); // Dismiss via 'N' app.pending_confirm = Some(ConfirmRequest::Elevate); - handle_key(&mut app, KeyCode::Char('N')); + handle_key(&mut app, Key::Char('N')); assert!(app.pending_confirm.is_none(), "'N' dismisses"); // Confirm via 'y' @@ -341,7 +362,7 @@ mod tests { release_url: "https://example.com".into(), download: None, }); - handle_key(&mut app, KeyCode::Char('y')); + handle_key(&mut app, Key::Char('y')); assert!(app.pending_confirm.is_none(), "'y' confirms"); // Confirm via 'Enter' @@ -351,7 +372,7 @@ mod tests { release_url: "https://example.com".into(), download: None, }); - handle_key(&mut app, KeyCode::Enter); + handle_key(&mut app, Key::Enter); assert!(app.pending_confirm.is_none(), "'Enter' confirms"); } @@ -361,35 +382,27 @@ mod tests { app.show_help = true; app.active_tab = TAB_DASHBOARD; - handle_key(&mut app, KeyCode::Char('3')); + handle_key(&mut app, Key::Char('3')); assert!(app.show_help, "still open"); assert_eq!(app.active_tab, TAB_DASHBOARD, "tab switch was swallowed"); - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert!(!app.show_help); } + /// `/` asks for the search box; it does not start capturing text itself. #[test] - fn search_mode_captures_letters_instead_of_triggering_bindings() { + fn slash_requests_the_search_box_only_on_the_triage_tab() { let mut app = app_with_issues(); - app.active_tab = TAB_TRIAGE; - app.is_searching = true; - - // 'q' and 's' are quit and scan outside search mode. - for c in "qs".chars() { - handle_key(&mut app, KeyCode::Char(c)); - } - assert_eq!(app.search_query, "qs"); - assert!(!app.should_quit); - assert!(!app.is_scanning); - - handle_key(&mut app, KeyCode::Backspace); - assert_eq!(app.search_query, "q"); + app.active_tab = TAB_DASHBOARD; + handle_key(&mut app, Key::Char('/')); + assert!(!app.focus_search, "nothing to focus outside triage"); - handle_key(&mut app, KeyCode::Enter); - assert!(!app.is_searching, "Enter leaves search mode"); - assert_eq!(app.search_query, "q", "and keeps the query"); + app.active_tab = TAB_TRIAGE; + handle_key(&mut app, Key::Char('/')); + assert!(app.focus_search); + assert_eq!(app.search_query, "", "and types nothing by itself"); } #[test] @@ -397,10 +410,10 @@ mod tests { let mut app = app(); app.active_tab = TAB_COUNT - 1; - handle_key(&mut app, KeyCode::Tab); + handle_key(&mut app, Key::Tab); assert_eq!(app.active_tab, 0); - handle_key(&mut app, KeyCode::BackTab); + handle_key(&mut app, Key::BackTab); assert_eq!(app.active_tab, TAB_COUNT - 1); } @@ -410,27 +423,27 @@ mod tests { app.active_tab = 0; // Right arrow advances tab - handle_key(&mut app, KeyCode::Right); + handle_key(&mut app, Key::Right); assert_eq!(app.active_tab, 1); // 'l' advances tab - handle_key(&mut app, KeyCode::Char('l')); + handle_key(&mut app, Key::Char('l')); assert_eq!(app.active_tab, 2); // Left arrow goes back - handle_key(&mut app, KeyCode::Left); + handle_key(&mut app, Key::Left); assert_eq!(app.active_tab, 1); // 'h' goes back - handle_key(&mut app, KeyCode::Char('h')); + handle_key(&mut app, Key::Char('h')); assert_eq!(app.active_tab, 0); // Left arrow wraps to last tab - handle_key(&mut app, KeyCode::Left); + handle_key(&mut app, Key::Left); assert_eq!(app.active_tab, TAB_COUNT - 1); // Right arrow wraps back to first tab - handle_key(&mut app, KeyCode::Right); + handle_key(&mut app, Key::Right); assert_eq!(app.active_tab, 0); } @@ -441,16 +454,16 @@ mod tests { app.selected_setting_index = 4; // temp_clean_threshold_mb (default 500) let initial = app.config.temp_clean_threshold_mb; - handle_key(&mut app, KeyCode::Char('+')); + handle_key(&mut app, Key::Char('+')); assert_eq!(app.config.temp_clean_threshold_mb, initial + 100); - handle_key(&mut app, KeyCode::Char('-')); + handle_key(&mut app, Key::Char('-')); assert_eq!(app.config.temp_clean_threshold_mb, initial); - handle_key(&mut app, KeyCode::Char(']')); + handle_key(&mut app, Key::Char(']')); assert_eq!(app.config.temp_clean_threshold_mb, initial + 100); - handle_key(&mut app, KeyCode::Char('[')); + handle_key(&mut app, Key::Char('[')); assert_eq!(app.config.temp_clean_threshold_mb, initial); } @@ -459,15 +472,15 @@ mod tests { let mut app = app_with_issues(); app.active_tab = TAB_DASHBOARD; - handle_key(&mut app, KeyCode::Char('c')); + handle_key(&mut app, Key::Char('c')); assert_eq!(app.severity_filter, None, "no filtering outside triage"); app.active_tab = TAB_TRIAGE; - handle_key(&mut app, KeyCode::Char('c')); + handle_key(&mut app, Key::Char('c')); assert_eq!(app.severity_filter, Some(Severity::Critical)); // Pressing it again clears it. - handle_key(&mut app, KeyCode::Char('c')); + handle_key(&mut app, Key::Char('c')); assert_eq!(app.severity_filter, None); } @@ -477,11 +490,11 @@ mod tests { app.active_tab = TAB_TRIAGE; app.toggle_severity_filter(Severity::Critical); - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert!(!app.has_active_filters(), "first Esc clears the filter"); assert_eq!(app.active_tab, TAB_TRIAGE, "and stays put"); - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert_eq!(app.active_tab, TAB_DASHBOARD, "second Esc navigates back"); } @@ -492,12 +505,12 @@ mod tests { app.available_update = None; // Nothing parked, so this is a no-op rather than a modal. - handle_key(&mut app, KeyCode::Char('u')); + handle_key(&mut app, Key::Char('u')); assert!(app.pending_confirm.is_none()); app.active_tab = TAB_SETTINGS; app.backup_records.clear(); - handle_key(&mut app, KeyCode::Char('u')); + handle_key(&mut app, Key::Char('u')); // No backups to roll back, so it explains itself instead. assert!(app.pending_confirm.is_none()); assert!(app.status_message.is_some()); @@ -511,12 +524,12 @@ mod tests { app.selected_setting_index = 0; // Focus starts on the settings list. - handle_key(&mut app, KeyCode::Down); + handle_key(&mut app, Key::Down); assert_eq!(app.selected_setting_index, 1); assert_eq!(app.selected_backup_index, 0, "the backup list stayed put"); - handle_key(&mut app, KeyCode::Char('b')); - handle_key(&mut app, KeyCode::Down); + handle_key(&mut app, Key::Char('b')); + handle_key(&mut app, Key::Down); assert_eq!(app.selected_backup_index, 1); assert_eq!( app.selected_setting_index, 1, @@ -526,17 +539,17 @@ mod tests { // While backups hold focus, the setting editors are inert. let before = app.config.temp_clean_threshold_mb; app.selected_setting_index = 4; // temp_clean_threshold_mb - handle_key(&mut app, KeyCode::Char('+')); + handle_key(&mut app, Key::Char('+')); assert_eq!(app.config.temp_clean_threshold_mb, before); - handle_key(&mut app, KeyCode::Enter); + handle_key(&mut app, Key::Enter); assert!(app.setting_input.is_none(), "Enter opens no input dialog"); // Esc gives the arrow keys back before it navigates anywhere. - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert!(!app.backups_focused()); assert_eq!(app.active_tab, TAB_SETTINGS, "and stays on the tab"); - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Esc); assert_eq!(app.active_tab, TAB_DASHBOARD); } @@ -545,7 +558,7 @@ mod tests { let mut app = app(); app.active_tab = TAB_TRIAGE; - handle_key(&mut app, KeyCode::Char('b')); + handle_key(&mut app, Key::Char('b')); assert!(!app.backups_focused()); } @@ -560,7 +573,7 @@ mod tests { ('4', TAB_REPAIR), ('5', TAB_SETTINGS), ] { - handle_key(&mut app, KeyCode::Char(key)); + handle_key(&mut app, Key::Char(key)); assert_eq!( app.active_tab, expected, "'{key}' should open tab {expected}" @@ -569,7 +582,7 @@ mod tests { // '6' used to be Settings. It now points past the last tab and must not // move the user anywhere. - handle_key(&mut app, KeyCode::Char('6')); + handle_key(&mut app, Key::Char('6')); assert_eq!(app.active_tab, TAB_SETTINGS, "'6' is no longer bound"); } @@ -580,42 +593,23 @@ mod tests { app.selected_filtered_index = 0; assert!(app.issues[0].is_selected, "issues start selected"); - handle_key(&mut app, KeyCode::Char(' ')); + handle_key(&mut app, Key::Char(' ')); assert!(!app.issues[0].is_selected); - handle_key(&mut app, KeyCode::Char(' ')); + handle_key(&mut app, Key::Char(' ')); assert!(app.issues[0].is_selected); } - #[test] - fn arrow_keys_scroll_logs_on_the_scanner_tab() { - let mut app = app(); - app.active_tab = TAB_SCANNER; - app.scan_log_messages.clear(); - for i in 0..50 { - app.push_scan_log(format!("line {i}")); - } - - handle_key(&mut app, KeyCode::PageUp); - assert_eq!(app.scan_log_scroll, 10); - - handle_key(&mut app, KeyCode::Up); - assert_eq!(app.scan_log_scroll, 11); - - handle_key(&mut app, KeyCode::End); - assert_eq!(app.scan_log_scroll, 0, "End returns to live"); - } - #[test] fn dry_run_toggles_and_reports_itself() { let mut app = app(); assert!(!app.dry_run); - handle_key(&mut app, KeyCode::Char('d')); + handle_key(&mut app, Key::Char('d')); assert!(app.dry_run); assert!(app.status_message.is_some()); - handle_key(&mut app, KeyCode::Char('D')); + handle_key(&mut app, Key::Char('D')); assert!(!app.dry_run); } @@ -623,7 +617,7 @@ mod tests { fn quit_is_bound_in_both_cases() { for c in ['q', 'Q'] { let mut app = app(); - handle_key(&mut app, KeyCode::Char(c)); + handle_key(&mut app, Key::Char(c)); assert!(app.should_quit, "'{c}' should quit"); } } @@ -633,12 +627,7 @@ mod tests { let mut app = app(); let before = app.active_tab; - for code in [ - KeyCode::F(5), - KeyCode::Insert, - KeyCode::Delete, - KeyCode::Char('§'), - ] { + for code in [Key::Unbound, Key::Char('§')] { handle_key(&mut app, code); } @@ -656,38 +645,90 @@ mod tests { assert_eq!(app.config.temp_clean_threshold_mb, 500); // Enter opens input modal - handle_key(&mut app, KeyCode::Enter); + handle_key(&mut app, Key::Enter); assert!(app.setting_input.is_some()); // Backspace 3 times - handle_key(&mut app, KeyCode::Backspace); - handle_key(&mut app, KeyCode::Backspace); - handle_key(&mut app, KeyCode::Backspace); + handle_key(&mut app, Key::Backspace); + handle_key(&mut app, Key::Backspace); + handle_key(&mut app, Key::Backspace); assert_eq!(app.setting_input.as_ref().unwrap().buffer, ""); // Type '8', '0', '0' - handle_key(&mut app, KeyCode::Char('8')); - handle_key(&mut app, KeyCode::Char('0')); - handle_key(&mut app, KeyCode::Char('0')); + handle_key(&mut app, Key::Char('8')); + handle_key(&mut app, Key::Char('0')); + handle_key(&mut app, Key::Char('0')); assert_eq!(app.setting_input.as_ref().unwrap().buffer, "800"); // Non-digits are ignored - handle_key(&mut app, KeyCode::Char('a')); - handle_key(&mut app, KeyCode::Char('q')); + handle_key(&mut app, Key::Char('a')); + handle_key(&mut app, Key::Char('q')); assert_eq!(app.setting_input.as_ref().unwrap().buffer, "800"); assert!(!app.should_quit, "modal swallows 'q'"); // Enter submits and saves - handle_key(&mut app, KeyCode::Enter); + handle_key(&mut app, Key::Enter); assert!(app.setting_input.is_none()); assert_eq!(app.config.temp_clean_threshold_mb, 800); // Esc cancels without saving - handle_key(&mut app, KeyCode::Enter); + handle_key(&mut app, Key::Enter); assert!(app.setting_input.is_some()); - handle_key(&mut app, KeyCode::Char('9')); - handle_key(&mut app, KeyCode::Esc); + handle_key(&mut app, Key::Char('9')); + handle_key(&mut app, Key::Esc); assert!(app.setting_input.is_none()); assert_eq!(app.config.temp_clean_threshold_mb, 800); } + + #[test] + fn triage_navigation_keys() { + let mut app = app(); + app.active_tab = TAB_TRIAGE; + for i in 0..15 { + app.issues.push(Issue::new( + format!("iss_{i}"), + "sys", + format!("Issue {i}"), + "Category", + Severity::Info, + RiskScore::Low, + "Desc", + "Details", + "Fix", + vec![], + )); + } + + assert_eq!(app.selected_filtered_index, 0); + + handle_key(&mut app, Key::Down); + assert_eq!(app.selected_filtered_index, 1); + + handle_key(&mut app, Key::Char('j')); + assert_eq!(app.selected_filtered_index, 2); + + handle_key(&mut app, Key::Up); + assert_eq!(app.selected_filtered_index, 1); + + handle_key(&mut app, Key::Char('k')); + assert_eq!(app.selected_filtered_index, 0); + + handle_key(&mut app, Key::End); + assert_eq!(app.selected_filtered_index, 14); + + handle_key(&mut app, Key::Home); + assert_eq!(app.selected_filtered_index, 0); + + handle_key(&mut app, Key::PageDown); + assert_eq!(app.selected_filtered_index, 10); + + handle_key(&mut app, Key::PageDown); + assert_eq!(app.selected_filtered_index, 14); + + handle_key(&mut app, Key::PageUp); + assert_eq!(app.selected_filtered_index, 4); + + handle_key(&mut app, Key::PageUp); + assert_eq!(app.selected_filtered_index, 0); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 42bb69d..b3b1565 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,4 @@ -//! Application state and behaviour for the interactive TUI. +//! Application state and behaviour for the interactive front end. //! //! This used to be one 1400-line file holding UI state, key dispatch, filtering, //! run orchestration, backup listing and the update notice together. The split @@ -26,11 +26,13 @@ pub mod filters; pub mod input; pub mod run_control; pub mod safety; +pub mod scan_state; pub mod settings; pub mod state; pub use confirm::{ConfirmRequest, SystemActions}; -pub use input::handle_key; +pub use input::{Key, handle_key}; +pub use scan_state::ScanState; pub use state::{App, SafetyFocus, SettingInput}; /// Maximum number of log lines kept in memory for scan and repair terminal buffers. diff --git a/src/app/run_control.rs b/src/app/run_control.rs index 1ecc23c..e7c8637 100644 --- a/src/app/run_control.rs +++ b/src/app/run_control.rs @@ -23,7 +23,6 @@ impl App { self.issues.clear(); self.selected_issue_index = 0; self.selected_filtered_index = 0; - self.scan_log_scroll = 0; self.scan_log_messages.clear(); self.push_scan_log("Starting a full system health scan..."); @@ -62,7 +61,7 @@ impl App { let selected_count = self .issues .iter() - .filter(|i| i.is_selected && !i.is_fixed) + .filter(|i| i.is_selected && !i.is_fixed && !i.is_reboot_pending) .count(); if selected_count == 0 { self.status_message = Some("No open issues selected for repair.".to_string()); @@ -74,9 +73,10 @@ impl App { self.fixed_count = 0; self.failed_count = 0; self.total_to_fix = selected_count; - self.repair_log_scroll = 0; self.vss_status = if self.dry_run { "Simulation".to_string() + } else if !self.config.create_vss_before_repair { + "Skipped".to_string() } else { "Initialising...".to_string() }; diff --git a/src/app/scan_state.rs b/src/app/scan_state.rs new file mode 100644 index 0000000..6e56106 --- /dev/null +++ b/src/app/scan_state.rs @@ -0,0 +1,114 @@ +//! Persistence for the latest scan state across app restarts. +//! +//! Stores the diagnosed issues, module statuses, health index and timestamp +//! in `%APPDATA%\WinMedic\last_scan.json`. + +use chrono::Local; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::engine::issue::Issue; +use crate::modules::ModuleStatus; + +pub const SCAN_STATE_FILE_NAME: &str = "last_scan.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScanState { + pub timestamp: String, + pub health_score: u8, + pub issues: Vec, + pub module_statuses: Vec<(String, String, String, ModuleStatus)>, + #[serde(default)] + pub scan_duration_secs: Option, + #[serde(default)] + pub boot_time_secs: Option, +} + +impl ScanState { + pub fn new( + health_score: u8, + issues: Vec, + module_statuses: Vec<(String, String, String, ModuleStatus)>, + scan_duration_secs: Option, + ) -> Self { + Self { + timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), + health_score, + issues, + module_statuses, + scan_duration_secs, + boot_time_secs: Some(sysinfo::System::boot_time()), + } + } + + pub fn file_path() -> PathBuf { + let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join("WinMedic").join(SCAN_STATE_FILE_NAME) + } + + pub fn load() -> Option { + Self::load_from(&Self::file_path()) + } + + pub fn load_from(path: &Path) -> Option { + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() + } + + pub fn save(&self) -> Result<(), std::io::Error> { + Self::save_to(self, &Self::file_path()) + } + + pub fn save_to(&self, path: &Path) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let json = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(path, json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::issue::{RiskScore, Severity}; + + #[test] + fn test_scan_state_serialization_round_trip() { + let tmp = std::env::temp_dir().join(format!( + "winmedic_scan_state_test_{}.json", + std::process::id() + )); + let issue = Issue::new( + "test_iss", + "storage", + "Corrupted volume", + "Storage", + Severity::Critical, + RiskScore::High, + "Desc", + "Details", + "Fix", + vec!["Step 1".to_string()], + ); + let statuses = vec![( + "storage".to_string(), + "Storage".to_string(), + "[DSK]".to_string(), + ModuleStatus::Critical(1), + )]; + + let state = ScanState::new(75, vec![issue], statuses, Some(12)); + state.save_to(&tmp).unwrap(); + + let loaded = ScanState::load_from(&tmp).expect("should load saved scan state"); + assert_eq!(loaded.health_score, 75); + assert_eq!(loaded.issues.len(), 1); + assert_eq!(loaded.issues[0].id, "test_iss"); + assert_eq!(loaded.module_statuses.len(), 1); + assert_eq!(loaded.scan_duration_secs, Some(12)); + + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/src/app/state.rs b/src/app/state.rs index 053958a..812683f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -2,10 +2,7 @@ //! export. Behaviour that belongs to a specific feature lives in the sibling //! modules listed in [`crate::app`]. -use super::{ - BackgroundEvent, TAB_COUNT, TAB_DASHBOARD, TAB_REPAIR, TAB_SCANNER, TAB_SETTINGS, - push_bounded_log, -}; +use super::{BackgroundEvent, ScanState, TAB_COUNT, TAB_DASHBOARD, TAB_SETTINGS, push_bounded_log}; use crate::config::AppConfig; use crate::engine::issue::{Issue, Severity}; use crate::engine::reporter::DiagnosticReporter; @@ -132,7 +129,14 @@ pub struct App { pub severity_filter: Option, pub module_filter: Option, pub search_query: String, - pub is_searching: bool, + /// A one-shot request from the `/` binding: put the caret in the triage + /// search box. + /// + /// The terminal front end had to capture every keystroke itself and append + /// it to [`Self::search_query`]. A window has a real text field bound to + /// that same string, so the only thing `/` still has to do is say where the + /// keyboard should go. The front end clears the flag as it honours it. + pub focus_search: bool, pub selected_filtered_index: usize, // Live Scanner State @@ -146,7 +150,6 @@ pub struct App { pub module_progress_list: Vec, pub module_statuses: Vec<(String, String, String, ModuleStatus)>, pub scan_log_messages: VecDeque, - pub scan_log_scroll: usize, // Live Repair State pub is_fixing: bool, @@ -158,7 +161,6 @@ pub struct App { pub total_to_fix: usize, pub vss_status: String, pub repair_console_lines: VecDeque, - pub repair_log_scroll: usize, // Safety: audit log, registry backups, VSS restore points pub audit_logger: AuditLogger, @@ -189,7 +191,7 @@ pub struct App { /// windows, UAC prompts, restore points. /// /// Inert unless the caller opts in through - /// [`App::enable_real_system_actions`], which only the TUI entry point + /// [`App::enable_real_system_actions`], which only the desktop front end /// does — see [`SystemActions`]. pub system_actions: SystemActions, pub should_quit: bool, @@ -225,7 +227,62 @@ impl App { let backup_records = reg_backup_mgr.list_backups(); let (bg_tx, bg_rx) = tokio::sync::mpsc::unbounded_channel(); - let (module_progress_list, module_statuses) = Self::module_lists(&engine); + let (module_progress_list, default_module_statuses) = Self::module_lists(&engine); + let (saved_issues, saved_health, module_statuses, saved_duration, init_msg) = if let Some( + mut saved, + ) = + ScanState::load() + { + let current_boot = sysinfo::System::boot_time(); + let rebooted = saved.boot_time_secs.is_some_and(|b| current_boot > b); + if rebooted { + for issue in &mut saved.issues { + if issue.is_reboot_pending { + issue.is_reboot_pending = false; + issue.is_fixed = true; + } + } + } + let health = DiagnosticEngine::calculate_health_score(&saved.issues); + let open_count = saved.issues.iter().filter(|i| !i.is_fixed).count(); + let msg = format!( + "WinMedic initialised. Loaded previous scan from {} ({} open issues, health: {}/100).", + saved.timestamp, open_count, health + ); + + // Reconcile saved module statuses with current engine modules so newly added modules appear + let mut reconciled_statuses = Vec::new(); + for (id, name, icon, def_status) in &default_module_statuses { + if let Some((_, _, _, st)) = + saved.module_statuses.iter().find(|(s_id, ..)| s_id == id) + { + reconciled_statuses.push((id.clone(), name.clone(), icon.clone(), st.clone())); + } else { + reconciled_statuses.push(( + id.clone(), + name.clone(), + icon.clone(), + def_status.clone(), + )); + } + } + + ( + saved.issues, + health, + reconciled_statuses, + saved.scan_duration_secs.map(Duration::from_secs), + msg, + ) + } else { + ( + Vec::new(), + 100, + default_module_statuses, + None, + "WinMedic initialised. Ready to diagnose.".to_string(), + ) + }; Self { active_tab: TAB_DASHBOARD, @@ -234,24 +291,21 @@ impl App { telemetry_collector, telemetry, engine, - issues: Vec::new(), + issues: saved_issues, selected_issue_index: 0, - health_score: 100, + health_score: saved_health, severity_filter: None, module_filter: None, search_query: String::new(), - is_searching: false, + focus_search: false, selected_filtered_index: 0, is_scanning: false, scan_overall_progress: 0, scan_started_at: None, - scan_duration: None, + scan_duration: saved_duration, module_progress_list, module_statuses, - scan_log_messages: VecDeque::from([String::from( - "WinMedic initialised. Ready to diagnose.", - )]), - scan_log_scroll: 0, + scan_log_messages: VecDeque::from([init_msg]), is_fixing: false, dry_run: false, current_fix_title: String::new(), @@ -260,7 +314,6 @@ impl App { total_to_fix: 0, vss_status: "Ready".to_string(), repair_console_lines: VecDeque::from([String::from("Repair centre ready.")]), - repair_log_scroll: 0, audit_logger, reg_backup_mgr, audit_entries, @@ -305,8 +358,8 @@ impl App { /// [`App::new`] builds an app that cannot touch it: confirming a dialog /// opens no browser and raises no UAC prompt, and a repair run asks Windows /// for no restore point. That default is what keeps `cargo test` — which - /// builds dozens of `App`s — off the developer's own desktop. The TUI entry - /// point is the one caller that wants the real thing, so it is the one + /// builds dozens of `App`s — off the developer's own desktop. The desktop + /// front end is the one caller that wants the real thing, so it is the one /// caller that opts in. /// /// The engine is rebuilt because it reads @@ -328,8 +381,8 @@ impl App { /// /// Deliberately *not* part of [`App::new`]: constructing an `App` must stay /// free of network I/O so the test suite — which builds dozens of them - /// inside `#[tokio::test]` — never reaches out to api.github.com. The TUI - /// entry point calls this once, right after construction. + /// inside `#[tokio::test]` — never reaches out to api.github.com. The + /// desktop front end calls this once, right after construction. pub fn start_update_check(&mut self) { if !self.config.check_for_updates { return; @@ -350,6 +403,29 @@ impl App { }); } + /// Whether any repaired issue currently requires a system restart. + pub fn has_pending_reboot(&self) -> bool { + self.issues.iter().any(|i| i.is_reboot_pending) + } + + /// Open the restart confirmation dialog if there are issues pending a reboot. + pub fn show_reboot_notice(&mut self) { + if self.pending_confirm.is_some() || self.is_fixing || self.is_scanning { + return; + } + let reboot_issues: Vec = self + .issues + .iter() + .filter(|i| i.is_reboot_pending) + .map(|i| i.title.clone()) + .collect(); + if !reboot_issues.is_empty() { + self.pending_confirm = Some(ConfirmRequest::RestartRequired { + issues: reboot_issues, + }); + } + } + #[allow(clippy::type_complexity)] pub(super) fn module_lists( engine: &DiagnosticEngine, @@ -442,56 +518,6 @@ impl App { push_bounded_log(&mut self.repair_console_lines, line); } - pub fn scroll_log_up(&mut self, amount: usize) { - match self.active_tab { - TAB_SCANNER => { - let max_scroll = self.scan_log_messages.len().saturating_sub(1); - self.scan_log_scroll = (self.scan_log_scroll + amount).min(max_scroll); - } - TAB_REPAIR => { - let max_scroll = self.repair_console_lines.len().saturating_sub(1); - self.repair_log_scroll = (self.repair_log_scroll + amount).min(max_scroll); - } - _ => {} - } - } - - pub fn scroll_log_down(&mut self, amount: usize) { - match self.active_tab { - TAB_SCANNER => { - self.scan_log_scroll = self.scan_log_scroll.saturating_sub(amount); - } - TAB_REPAIR => { - self.repair_log_scroll = self.repair_log_scroll.saturating_sub(amount); - } - _ => {} - } - } - - pub fn scroll_log_top(&mut self) { - match self.active_tab { - TAB_SCANNER => { - self.scan_log_scroll = self.scan_log_messages.len().saturating_sub(1); - } - TAB_REPAIR => { - self.repair_log_scroll = self.repair_console_lines.len().saturating_sub(1); - } - _ => {} - } - } - - pub fn scroll_log_bottom(&mut self) { - match self.active_tab { - TAB_SCANNER => { - self.scan_log_scroll = 0; - } - TAB_REPAIR => { - self.repair_log_scroll = 0; - } - _ => {} - } - } - /// Export the current scan/repair report as an HTML file in the reports directory. pub fn export_report(&mut self) -> Result { let base = dirs::data_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); @@ -507,12 +533,24 @@ impl App { .map(|_| path) .map_err(|e| format!("Export failed: {}", e)) } + + /// Persist the latest scan results, health score, and module statuses to disk. + pub fn save_scan_state(&self) { + let state = ScanState::new( + self.health_score, + self.issues.clone(), + self.module_statuses.clone(), + self.scan_duration.map(|d| d.as_secs()), + ); + let _ = state.save(); + } } #[cfg(test)] mod tests { use super::*; use crate::app::MAX_LOG_LINES; + use crate::engine::issue::RiskScore; #[test] fn test_app_export_report() { @@ -527,7 +565,7 @@ mod tests { } #[test] - fn test_app_log_ring_buffer_and_scrolling() { + fn the_scan_log_evicts_its_oldest_lines_once_it_is_full() { let mut app = App::new(); app.scan_log_messages.clear(); @@ -546,22 +584,6 @@ mod tests { app.scan_log_messages.back(), Some(&"Log line 2099".to_string()) ); - - // Test scrolling - app.active_tab = TAB_SCANNER; - assert_eq!(app.scan_log_scroll, 0); - - app.scroll_log_up(15); - assert_eq!(app.scan_log_scroll, 15); - - app.scroll_log_down(5); - assert_eq!(app.scan_log_scroll, 10); - - app.scroll_log_top(); - assert_eq!(app.scan_log_scroll, MAX_LOG_LINES - 1); - - app.scroll_log_bottom(); - assert_eq!(app.scan_log_scroll, 0); } #[test] @@ -578,4 +600,40 @@ mod tests { app.next_tab(); assert_eq!(app.active_tab, 1); } + + #[test] + fn test_app_persists_and_restores_scan_state() { + let tmp = + std::env::temp_dir().join(format!("winmedic_state_test_{}.json", std::process::id())); + let mut app = App::new(); + app.issues = vec![Issue::new( + "iss_1", + "storage", + "Low Disk Space", + "Storage", + Severity::Warning, + RiskScore::Low, + "Temp files are taking up too much space", + "5 GB in temp directory", + "Clean temp files", + vec!["Delete temp files".to_string()], + )]; + app.health_score = 85; + + let state = ScanState::new( + app.health_score, + app.issues.clone(), + app.module_statuses.clone(), + Some(5), + ); + state.save_to(&tmp).unwrap(); + + let loaded = ScanState::load_from(&tmp).expect("should load scan state"); + assert_eq!(loaded.health_score, 85); + assert_eq!(loaded.issues.len(), 1); + assert_eq!(loaded.issues[0].title, "Low Disk Space"); + assert_eq!(loaded.scan_duration_secs, Some(5)); + + let _ = std::fs::remove_file(tmp); + } } diff --git a/src/engine/issue.rs b/src/engine/issue.rs index 3eae384..adc4b99 100644 --- a/src/engine/issue.rs +++ b/src/engine/issue.rs @@ -43,7 +43,7 @@ impl RiskScore { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Issue { pub id: String, pub module_id: String, @@ -57,6 +57,10 @@ pub struct Issue { pub fix_steps: Vec, pub is_selected: bool, pub is_fixed: bool, + #[serde(default)] + pub requires_reboot: bool, + #[serde(default)] + pub is_reboot_pending: bool, pub fix_error: Option, pub timestamp: String, } @@ -92,8 +96,15 @@ impl Issue { fix_steps, is_selected: true, is_fixed: false, + requires_reboot: false, + is_reboot_pending: false, fix_error: None, timestamp: Local::now().format("%H:%M:%S").to_string(), } } + + pub fn with_requires_reboot(mut self, requires_reboot: bool) -> Self { + self.requires_reboot = requires_reboot; + self + } } diff --git a/src/engine/runner.rs b/src/engine/runner.rs index e83b7ab..8302224 100644 --- a/src/engine/runner.rs +++ b/src/engine/runner.rs @@ -244,8 +244,8 @@ impl DiagnosticEngine { /// Choose where `run_repairs` gets its restore point from. /// /// Only the two entry points that repair a real machine pass - /// [`RestorePointService::real`] here — the TUI (through - /// [`crate::app::SystemActions`]) and the `--fix` command line. + /// [`RestorePointService::real`] here — the desktop front end (through + /// [`crate::app::SystemActions`]) and the `--auto-fix` command line. pub fn with_restore_points(mut self, service: RestorePointService) -> Self { self.restore_point = service; self @@ -784,7 +784,14 @@ impl DiagnosticEngine { match result { Ok(msg) => { - issue.is_fixed = true; + if issue.requires_reboot { + issue.is_reboot_pending = true; + issue.is_fixed = false; + issue.is_selected = false; + } else { + issue.is_fixed = true; + issue.is_reboot_pending = false; + } issue.fix_error = None; fixed_count += 1; @@ -801,6 +808,7 @@ impl DiagnosticEngine { } Err(err) => { issue.is_fixed = false; + issue.is_reboot_pending = false; issue.fix_error = Some(err.clone()); failed_count += 1; @@ -879,7 +887,7 @@ mod tests { /// Deliberately a literal rather than `get_all_modules().len()`: derived /// from the registry these assertions would be tautologies, and would stop /// noticing a module that quietly fell out of it. - const MODULE_COUNT: usize = 9; + const MODULE_COUNT: usize = 11; fn sample_issues() -> Vec { vec![ @@ -997,7 +1005,8 @@ mod tests { #[tokio::test] async fn test_cancelled_scan_reports_progress() { - let engine = DiagnosticEngine::new(&AppConfig::default()); + let mock = Arc::new(crate::utils::cmd::MockCommandRunner::new()); + let engine = DiagnosticEngine::with_runner(&AppConfig::default(), mock); let (tx, mut rx) = channel::(200); let cancel = CancellationToken::new(); @@ -1021,7 +1030,8 @@ mod tests { #[tokio::test] async fn test_run_scan_emits_events_and_completes() { - let engine = DiagnosticEngine::new(&AppConfig::default()); + let mock = Arc::new(crate::utils::cmd::MockCommandRunner::new()); + let engine = DiagnosticEngine::with_runner(&AppConfig::default(), mock); let (tx, mut rx) = channel::(200); let _issues = engine.run_scan(tx, CancellationToken::new()).await; @@ -1101,7 +1111,8 @@ mod tests { verbose_logging: true, ..Default::default() }; - let engine = DiagnosticEngine::new(&config); + let mock = Arc::new(crate::utils::cmd::MockCommandRunner::new()); + let engine = DiagnosticEngine::with_runner(&config, mock); assert!(engine.verbose_logging); let (tx, mut rx) = channel::(200); diff --git a/src/gui/keys.rs b/src/gui/keys.rs new file mode 100644 index 0000000..499fdaa --- /dev/null +++ b/src/gui/keys.rs @@ -0,0 +1,67 @@ +//! Translating egui's key events into the toolkit-neutral [`Key`]. +//! +//! The mirror image of what the terminal front end did with crossterm: the +//! front end names its own key type, [`crate::app::input`] names none. Keeping +//! the whole dispatch table on the far side of this translation is what let it +//! survive the move from terminal to window unchanged. + +use crate::app::Key; +use eframe::egui; + +/// Collect this frame's keystrokes, in the order they arrived. +/// +/// Returns nothing at all while a text field holds focus. That is the rule that +/// makes single-letter bindings safe in a window: typing "storage" into the +/// triage search box must not start a scan on the `s`, and the widget that owns +/// the keyboard is the one egui already tracks. +pub fn shortcuts(ctx: &egui::Context) -> Vec { + if ctx.memory(|memory| memory.focused().is_some()) { + return Vec::new(); + } + + ctx.input(|input| input.events.iter().filter_map(translate).collect()) +} + +fn translate(event: &egui::Event) -> Option { + match event { + // Characters come from `Text` rather than from `Key`, and only from + // there. egui emits both for a printable key, so reading them from both + // would dispatch every letter twice — pressing `d` would toggle + // simulation mode on and straight back off again. + egui::Event::Text(text) => text.chars().next().map(Key::Char), + egui::Event::Key { + key, + pressed: true, + modifiers, + .. + } => named_key(*key, *modifiers), + _ => None, + } +} + +fn named_key(key: egui::Key, modifiers: egui::Modifiers) -> Option { + Some(match key { + egui::Key::Enter => Key::Enter, + egui::Key::Escape => Key::Esc, + egui::Key::Backspace => Key::Backspace, + egui::Key::ArrowUp => Key::Up, + egui::Key::ArrowDown => Key::Down, + egui::Key::ArrowLeft => Key::Left, + egui::Key::ArrowRight => Key::Right, + egui::Key::PageUp => Key::PageUp, + egui::Key::PageDown => Key::PageDown, + egui::Key::Home => Key::Home, + egui::Key::End => Key::End, + // Bare Tab belongs to egui, which moves focus between widgets with it — + // taking it away would strip the window of keyboard navigation. Ctrl+Tab + // is free, and is what Windows applications cycle views with anyway. + egui::Key::Tab if modifiers.ctrl => { + if modifiers.shift { + Key::BackTab + } else { + Key::Tab + } + } + _ => return None, + }) +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..e10e223 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,604 @@ +//! The desktop front end. +//! +//! This replaced a ratatui terminal interface, and the replacement was mostly a +//! matter of drawing: [`crate::app::App`] holds the state, [`crate::engine`] +//! reports over channels, and neither had to learn anything about windows. The +//! immediate-mode loop below does what the terminal draw loop did — drain the +//! channels, refresh telemetry once a second, render every frame from state — +//! because that is the shape the application already had. +//! +//! | Module | Responsibility | +//! | --- | --- | +//! | [`theme`] | Palette, shared surfaces, severity colours | +//! | [`keys`] | egui key events into the neutral [`crate::app::Key`] | +//! | [`modals`] | Confirmation, setting entry and help overlays | +//! | [`views`] | One module per tab | + +pub mod keys; +pub mod modals; +pub mod theme; +pub mod views; + +use crate::app::{ + App, TAB_DASHBOARD, TAB_REPAIR, TAB_SCANNER, TAB_SETTINGS, TAB_TRIAGE, handle_key, +}; +use eframe::egui::{self, RichText}; +use std::time::{Duration, Instant}; + +/// The tab strip, in order. The indices are the `TAB_*` constants. +const TABS: [&str; 5] = [ + "Dashboard", + "Health Scan", + "Issue Triage", + "Repair Center", + "Settings & Safety", +]; + +/// How often to redraw while a scan or repair run is in flight. +/// +/// The terminal front end polled every 40ms and this matches it: fast enough +/// that a progress bar moves smoothly, slow enough to stay off the CPU. +const BUSY_REPAINT: Duration = Duration::from_millis(40); + +/// How often to redraw when nothing is running. +/// +/// Not zero, because the header carries live CPU and memory figures that would +/// otherwise freeze until the user moved the mouse. +const IDLE_REPAINT: Duration = Duration::from_millis(500); + +pub struct WinMedicApp { + app: App, + last_telemetry_tick: Instant, +} + +/// Draw the whole frame: header, tab strip, body, status bar and overlays. +/// +/// Separate from [`WinMedicApp::ui`], which owns the parts a test has no use +/// for — reading the keyboard and closing the window — so that a test can put +/// an [`App`] in a given state and assert on what the window then says. +pub fn show(ui: &mut egui::Ui, app: &mut App) { + // Cloned because the overlays are windows, which are addressed on the + // context rather than nested inside a `Ui`. + let ctx = ui.ctx().clone(); + + egui::Panel::top("header").show(ui, |ui| { + header(ui, app); + tab_bar(ui, app); + }); + + egui::Panel::bottom("footer").show(ui, |ui| { + footer(ui, app); + }); + + egui::CentralPanel::default().show(ui, |ui| { + body(ui, app); + }); + + // Overlays, in the order the terminal front end stacked them: a pending + // confirmation outranks a setting being edited, which outranks help. + modals::show(&ctx, app); +} + +impl WinMedicApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + theme::apply(&cc.egui_ctx); + + let mut app = App::new(); + // `App::new` builds an app that cannot touch the desktop. This is the + // one place that wants it to: accepting the update dialog should really + // open a browser, accepting the elevation dialog should really raise + // UAC, and a repair run should really leave a restore point behind. + app.enable_real_system_actions(); + app.start_update_check(); + + Self { + app, + last_telemetry_tick: Instant::now(), + } + } +} + +fn header(ui: &mut egui::Ui, app: &mut App) { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("WinMedic v{}", env!("CARGO_PKG_VERSION"))) + .color(theme::CYAN) + .strong() + .size(16.0), + ); + ui.label(theme::muted("Windows Self-Healing Engine")); + + if app.dry_run { + theme::badge(ui, "SIMULATION", theme::AMBER); + } + + // A repair that has done its half of the work and is waiting on the + // machine. Saying so beside the brand keeps it visible from every tab, + // which is the point: the findings behind it read as unfixed until the + // restart happens. + if app.has_pending_reboot() { + theme::badge(ui, "REBOOT PENDING", theme::AMBER); + } + + // Right-aligned, so the system readout sits opposite the brand and + // does not shift as the numbers change width. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if app.is_admin { + theme::badge(ui, "ADMIN", theme::EMERALD); + } else { + theme::badge(ui, "NO ADMIN", theme::CORAL); + } + + match app.telemetry.as_ref() { + Some(telemetry) => { + ui.label(theme::muted(format!( + "{} {}", + telemetry.os_name, telemetry.os_version + ))); + ui.separator(); + ui.label(format!( + "RAM {:.1}/{:.1} GB", + telemetry.ram_used_mb as f32 / 1024.0, + telemetry.ram_total_mb as f32 / 1024.0 + )); + ui.separator(); + ui.label(format!("CPU {:.1}%", telemetry.cpu_usage)); + } + None => { + ui.label(theme::muted("Reading system telemetry...")); + } + } + }); + }); + ui.add_space(4.0); +} + +fn tab_bar(ui: &mut egui::Ui, app: &mut App) { + let open_issues = app.issues.iter().filter(|i| !i.is_fixed).count(); + + ui.horizontal(|ui| { + for (index, title) in TABS.iter().enumerate() { + // The two tabs that carry live information say so in the strip, + // so a user watching another tab still sees a scan finish. + let label = match index { + TAB_SCANNER if app.is_scanning => format!("{title} (running)"), + TAB_TRIAGE if open_issues > 0 => format!("{title} [{open_issues}]"), + _ => (*title).to_string(), + }; + + let selected = app.active_tab == index; + let text = if selected { + RichText::new(label).color(theme::CYAN).strong() + } else { + RichText::new(label).color(theme::MUTED) + }; + + if ui.selectable_label(selected, text).clicked() { + app.goto_tab(index); + } + } + }); + ui.add_space(2.0); +} + +fn footer(ui: &mut egui::Ui, app: &mut App) { + ui.add_space(3.0); + ui.horizontal(|ui| { + if app.dry_run { + theme::badge(ui, "SIMULATION", theme::AMBER); + } + + match app.status_message.as_deref() { + Some(message) => { + ui.label(RichText::new(message).color(theme::EMERALD)); + } + None => { + ui.label(theme::muted("Ready")); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Help").clicked() { + app.show_help = true; + } + if app.is_busy() && ui.button("Cancel").clicked() { + app.cancel_current_operation(); + } + }); + }); + ui.add_space(3.0); +} + +fn body(ui: &mut egui::Ui, app: &mut App) { + match app.active_tab { + TAB_DASHBOARD => views::dashboard::show(ui, app), + TAB_SCANNER => views::scanner::show(ui, app), + TAB_TRIAGE => views::triage::show(ui, app), + TAB_REPAIR => views::repair::show(ui, app), + TAB_SETTINGS => views::settings::show(ui, app), + _ => {} + } +} + +impl eframe::App for WinMedicApp { + /// Everything that is not drawing. + /// + /// eframe calls this before every `ui` pass, and — unlike `ui` — it keeps + /// calling it while the window is hidden. That is exactly what this work + /// needs: a scan started before the window was minimised must still drain + /// its channels, or it would appear to have frozen the moment it was out of + /// sight and finish all at once on the way back. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.app.process_background_events(); + + if self.last_telemetry_tick.elapsed() >= Duration::from_secs(1) { + self.app.refresh_telemetry(); + self.last_telemetry_tick = Instant::now(); + } + + // egui only repaints in response to input, and almost nothing this + // window shows is driven by input: progress bars, log lines and the CPU + // readout in the header all come from work happening elsewhere. + ctx.request_repaint_after(if self.app.is_busy() { + BUSY_REPAINT + } else { + IDLE_REPAINT + }); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + for key in keys::shortcuts(ui.ctx()) { + handle_key(&mut self.app, key); + } + + show(ui, &mut self.app); + + if self.app.should_quit { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ConfirmRequest, TAB_COUNT}; + use crate::safety::audit::AuditEntry; + use crate::safety::reg_backup::BackupRecord; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + /// Drive one frame of the whole window and hand back the result to query. + /// + /// The terminal front end's equivalent read the character buffer back out + /// of a `TestBackend`. A window has no character buffer, so these assert + /// against the accessibility tree instead — which is the more honest + /// target anyway: it is what a screen reader is given, so a label missing + /// from it is missing for a user, not merely for a test. + fn window(app: App) -> Harness<'static, App> { + let mut harness = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut App| show(ui, app), app); + harness.run(); + harness + } + + fn populated_app() -> App { + let mut app = App::new(); + // `App::new` raises the elevation prompt when WinMedic is not running + // as Administrator, and that modal covers the tab it is asked about. + app.pending_confirm = None; + // It also restores the last scan from `%APPDATA%`, which on a machine + // that has actually run WinMedic is a real one — and these tests + // describe the findings they want to draw. Start from nothing scanned; + // the tests that need issues add their own. + app.issues.clear(); + app.health_score = 100; + app.selected_filtered_index = 0; + app.backup_records = vec![BackupRecord { + id: "b1".to_string(), + timestamp: "2026-01-01 12:00:00".to_string(), + description: "Startup entry removed".to_string(), + key_path: r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run".to_string(), + file_path: r"C:\backups\b1.reg".to_string(), + }]; + app.vss_restore_points = vec!["2026-01-01 11:59 - WinMedic pre-repair".to_string()]; + app.audit_entries = vec![AuditEntry { + timestamp: "2026-01-01 12:00:00".to_string(), + action_type: "FIX".to_string(), + module_id: "registry_startup".to_string(), + title: "Disabled a startup entry".to_string(), + status: "SUCCESS".to_string(), + details: String::new(), + }]; + + // A finished run, so the Health Scan and Repair Center tabs are drawn + // with something in them. Their empty states are what a fresh `App` + // already gives; what these two tabs exist to show is a run. + app.scan_overall_progress = 100; + app.scan_duration = Some(Duration::from_secs(74)); + app.push_scan_log("Module 'network' finished."); + // Shaped the way `app::events` shapes it when the engine reports: a + // module that failed carries both the reason and the step line that + // states it, because the step line is what the scanner draws. + if let Some(module) = app.module_progress_list.first_mut() { + module.percent = 100; + module.is_done = true; + module.step = "Finished - 2 findings".to_string(); + } + if let Some(module) = app.module_progress_list.get_mut(1) { + module.percent = 100; + module.is_done = true; + module.failure = Some("DISM returned 0x800f081f".to_string()); + module.step = "Failed - DISM returned 0x800f081f".to_string(); + } + + app.total_to_fix = 3; + app.fixed_count = 2; + app.failed_count = 1; + app.current_fix_title = "Flushing the DNS resolver cache".to_string(); + app.vss_status = "Created".to_string(); + app.push_repair_log("[OK] DNS cache flushed."); + app.push_repair_log("[X] Failed: access denied."); + + app + } + + /// Layout only fails at draw time, so a tab no window size can satisfy + /// fails here rather than in front of a user. + #[test] + fn every_tab_draws_at_small_and_large_window_sizes() { + for (tab, title) in TABS.iter().enumerate() { + for size in [(960.0, 640.0), (1400.0, 900.0), (1920.0, 1200.0)] { + let mut app = populated_app(); + app.active_tab = tab; + + let mut harness = Harness::builder() + .with_size(egui::vec2(size.0, size.1)) + .build_ui_state(|ui, app: &mut App| show(ui, app), app); + harness.run(); + + assert!( + harness.query_by_label_contains(title).is_some(), + "tab {tab} at {}x{} did not draw its own strip entry", + size.0, + size.1 + ); + } + } + } + + #[test] + fn the_strip_offers_exactly_five_tabs() { + // The strip and the `TAB_*` constants index each other, so they have to + // agree; a label added to one without the other silently misroutes + // every click past it. + assert_eq!(TABS.len(), TAB_COUNT); + + let harness = window(populated_app()); + + for title in TABS { + assert!( + harness.query_by_label_contains(title).is_some(), + "missing tab: {title}" + ); + } + + // The sixth tab was merged into Settings & Safety and must not come + // back as a label the user can look for. + assert!(harness.query_by_label_contains("Backups & Logs").is_none()); + } + + /// The whole point of that merge: none of the safety surface may go missing. + #[test] + fn the_settings_tab_carries_the_whole_safety_surface() { + let mut app = populated_app(); + app.active_tab = TAB_SETTINGS; + let harness = window(app); + + for expected in [ + "SETTINGS", + "REGISTRY BACKUPS", + "SYSTEM RESTORE POINTS", + "LOGS & BACKUPS", + "Startup entry removed", + "Restore the selected snapshot", + ] { + assert!( + harness.query_by_label_contains(expected).is_some(), + "the safety surface lost: {expected}" + ); + } + } + + #[test] + fn the_dashboard_links_back_to_the_audit_trail_it_does_not_own() { + let mut app = populated_app(); + app.active_tab = TAB_DASHBOARD; + let harness = window(app); + + assert!(harness.query_by_label_contains("Last action:").is_some()); + assert!( + harness + .query_by_label_contains("Disabled a startup entry") + .is_some() + ); + assert!( + harness + .query_by_label_contains("Full log, backups & rollback") + .is_some(), + "and points at the tab that now holds it" + ); + } + + #[test] + fn a_machine_with_no_audit_trail_shows_no_empty_last_action_row() { + let mut app = populated_app(); + app.active_tab = TAB_DASHBOARD; + app.audit_entries.clear(); + + let harness = window(app); + assert!(harness.query_by_label_contains("Last action:").is_none()); + } + + /// A confirmation is a question about the machine, and it has to be visible + /// whichever tab the user happened to be on when it was raised. + #[test] + fn a_pending_confirmation_is_drawn_over_every_tab() { + for tab in 0..TAB_COUNT { + let mut app = populated_app(); + app.active_tab = tab; + app.pending_confirm = Some(ConfirmRequest::Elevate); + + let harness = window(app); + assert!( + harness + .query_by_label_contains("ADMINISTRATOR PRIVILEGES REQUIRED") + .is_some(), + "the elevation dialog is missing on tab {tab}" + ); + assert!( + harness + .query_by_label_contains("Continue without Administrator") + .is_some(), + "and offers no way out on tab {tab}" + ); + } + } + + /// Every character the window draws must exist in the fonts egui bundles. + /// + /// This guard has a specific history. The settings pane announced which + /// list owned the arrow keys with the glyphs the terminal front end used, + /// and all three reached the screen as empty boxes: a terminal inherits the + /// system's font coverage, while a window ships its own, and egui's is a + /// Latin text face with no arrows or geometric shapes in it. Nothing failed + /// — it just looked broken. + #[test] + fn every_character_the_window_draws_has_a_glyph() { + for tab in 0..TAB_COUNT { + let mut app = populated_app(); + app.active_tab = tab; + // Machine-supplied text — the CPU model, the host name — is not + // ours to word and differs per machine, which would make this + // assert on the test runner's hardware instead of on WinMedic. + app.telemetry = None; + + let harness = window(app); + + // Both properties, because AccessKit splits them: an interactive + // widget carries its text in `label`, while a plain piece of text — + // which is most of what this window is — is a node of role `Label` + // carrying its text in `value`. Reading only the former collects + // the buttons and none of the prose. + let drawn = std::cell::RefCell::new(Vec::new()); + harness + .query_all_by(|node| { + let mut drawn = drawn.borrow_mut(); + drawn.extend(node.label()); + drawn.extend(node.value()); + false + }) + .count(); + let drawn = drawn.into_inner(); + assert!( + drawn.len() > 20, + "tab {tab} produced only {} strings, so this asserts on nothing", + drawn.len() + ); + + let font = egui::FontId::proportional(14.0); + harness.ctx.fonts_mut(|fonts| { + for label in &drawn { + for character in label.chars() { + assert!( + fonts.has_glyph(&font, character), + "tab {tab}: no glyph for U+{:04X} ({character:?}) in {label:?}", + character as u32 + ); + } + } + }); + } + } + + /// A finished run has to be legible after the fact, not only while it runs: + /// how long it took, which module gave up and why, and what the repair pass + /// managed. The failure line matters most — a module that quit mid-scan is + /// the single thing a user most needs to see. + #[test] + fn the_scan_and_repair_tabs_report_a_finished_run() { + let mut app = populated_app(); + app.active_tab = TAB_SCANNER; + let harness = window(app); + + assert!( + harness.query_by_label_contains("1m 14s").is_some(), + "the scan duration is missing" + ); + assert!( + harness + .query_by_label_contains("DISM returned 0x800f081f") + .is_some(), + "a module that failed must say so" + ); + assert!( + harness + .query_by_label_contains("Module 'network' finished.") + .is_some(), + "the scan log is missing" + ); + + let mut app = populated_app(); + app.active_tab = TAB_REPAIR; + let harness = window(app); + + assert!(harness.query_by_label_contains("2 repaired").is_some()); + assert!(harness.query_by_label_contains("1 failed").is_some()); + assert!(harness.query_by_label_contains("VSS: Created").is_some()); + assert!( + harness + .query_by_label_contains("[X] Failed: access denied.") + .is_some(), + "the repair console is missing" + ); + } + + /// An empty triage tab has to say why it is empty, and the two reasons are + /// not the same: nothing scanned yet, versus nothing left after filtering. + #[test] + fn the_triage_tab_distinguishes_no_scan_from_no_matches() { + let mut app = populated_app(); + app.active_tab = TAB_TRIAGE; + let harness = window(app); + assert!( + harness + .query_by_label_contains("No scan has run yet.") + .is_some() + ); + + let mut app = populated_app(); + app.active_tab = TAB_TRIAGE; + app.issues = vec![crate::engine::issue::Issue::new( + "a", + "network", + "DNS cache full", + "Network", + crate::engine::issue::Severity::Critical, + crate::engine::issue::RiskScore::Low, + "description", + "details", + "fix", + vec![], + )]; + app.search_query = "nothing matches this".to_string(); + + let harness = window(app); + assert!( + harness + .query_by_label_contains("Nothing matches the current filters.") + .is_some() + ); + } +} diff --git a/src/gui/modals.rs b/src/gui/modals.rs new file mode 100644 index 0000000..b8b53dd --- /dev/null +++ b/src/gui/modals.rs @@ -0,0 +1,189 @@ +//! The three overlays, stacked in the order the terminal front end stacked them. +//! +//! Precedence matters and is not cosmetic: a pending confirmation is a question +//! about the machine that must be answered before anything else happens, so it +//! outranks a setting being edited, which in turn outranks the help sheet. +//! [`crate::app::input`] enforces exactly the same order for the keyboard. + +use crate::app::App; +use crate::gui::theme; +use eframe::egui::{self, RichText}; + +pub fn show(ctx: &egui::Context, app: &mut App) { + if app.pending_confirm.is_some() { + confirm(ctx, app); + } else if app.setting_input.is_some() { + setting_input(ctx, app); + } else if app.show_help { + help(ctx, app); + } +} + +/// The frame every overlay shares: modal, centred, not resizable. +fn modal_window<'a>(title: &'a str) -> egui::Window<'a> { + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) +} + +fn confirm(ctx: &egui::Context, app: &mut App) { + let Some(request) = app.pending_confirm.as_ref() else { + return; + }; + + // Cloned out of `app` before the closure, which needs `app` mutably in + // order to answer the question the request is asking. + let title = request.title(); + let body = request.body(); + let confirm_label = request.confirm_label(); + let dismiss_label = request.dismiss_label(); + + let mut confirmed = false; + let mut dismissed = false; + + modal_window(title).show(ctx, |ui| { + ui.set_max_width(560.0); + + for line in &body { + if line.is_empty() { + ui.add_space(6.0); + } else { + ui.label(line); + } + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(6.0); + + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new(RichText::new(confirm_label).color(theme::BG_DEEP).strong()) + .fill(theme::CYAN), + ) + .clicked() + { + confirmed = true; + } + if ui.button(dismiss_label).clicked() { + dismissed = true; + } + }); + }); + + if confirmed { + app.confirm_pending_action(); + } else if dismissed { + app.dismiss_confirm(); + } +} + +fn setting_input(ctx: &egui::Context, app: &mut App) { + let mut submitted = false; + let mut cancelled = false; + + // Held across the closure so the text field can write straight into the + // buffer `submit_setting_input` validates. + let Some(input) = app.setting_input.as_mut() else { + return; + }; + let title = input.setting_name.clone(); + let unit = input.unit.clone(); + let min = input.min_value; + let max = input.max_value; + let error = input.error_msg.clone(); + + modal_window("Edit setting").show(ctx, |ui| { + ui.set_max_width(420.0); + + ui.label(RichText::new(&title).color(theme::CYAN).strong()); + ui.label(theme::muted(format!("Allowed: {min}–{max} {unit}"))); + ui.add_space(8.0); + + let field = ui.add( + egui::TextEdit::singleline(&mut input.buffer) + .desired_width(f32::INFINITY) + .hint_text("Enter a number"), + ); + // Opening the dialog should put the caret in the field, the way the + // terminal front end put every keystroke there. + if field.changed() { + input.error_msg = None; + } + field.request_focus(); + + if let Some(message) = error.as_deref() { + ui.add_space(4.0); + ui.label(RichText::new(message).color(theme::CORAL)); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui.button("Save").clicked() || enter_pressed { + submitted = true; + } + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancelled = true; + } + }); + }); + + if submitted { + app.submit_setting_input(); + } else if cancelled { + app.cancel_setting_input(); + } +} + +fn help(ctx: &egui::Context, app: &mut App) { + let mut close = false; + + modal_window("Keyboard shortcuts").show(ctx, |ui| { + ui.set_max_width(520.0); + ui.label(theme::muted( + "Every action below is also a button in the window; these are the shortcuts.", + )); + ui.add_space(8.0); + + for (keys, description) in SHORTCUTS { + ui.horizontal(|ui| { + ui.label(RichText::new(*keys).color(theme::CYAN).strong()); + ui.label(*description); + }); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(6.0); + if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + }); + + if close { + app.show_help = false; + } +} + +const SHORTCUTS: &[(&str, &str)] = &[ + ("1 – 5", "Jump to a tab"), + ("Ctrl+Tab / Ctrl+Shift+Tab", "Cycle through the tabs"), + ("S / R", "Start a health scan"), + ("F", "Repair the selected issues"), + ("D", "Toggle simulation mode"), + ("A / N", "Select or deselect every issue"), + ("Space", "Toggle the highlighted issue"), + ("C / W / I", "Filter by critical, warning or info"), + ("M / X", "Cycle the module filter, clear all filters"), + ("E", "Export an HTML report"), + ("U", "Roll back a registry backup (Settings & Safety)"), + ("B", "Move focus between settings and backups"), + ( + "Esc", + "Unwind: filters, then focus, then a running operation", + ), + ("Q", "Quit"), +]; diff --git a/src/gui/theme.rs b/src/gui/theme.rs new file mode 100644 index 0000000..d12b249 --- /dev/null +++ b/src/gui/theme.rs @@ -0,0 +1,115 @@ +//! The visual identity, carried over unchanged from the terminal front end. +//! +//! The palette is the same set of colours WinMedic has always used, because it +//! is the same product: cyan is the brand, and emerald / amber / coral are the +//! three severities the whole tool is organised around. What changed is only +//! how they reach the screen — egui takes `Color32` where ratatui took `Color`. + +use crate::engine::issue::Severity; +use eframe::egui::{self, Color32, RichText, Stroke, Visuals}; + +// Cyber-Medic / Dark Slate palette. +/// Primary brand colour. +pub const CYAN: Color32 = Color32::from_rgb(0, 210, 255); +/// Success / healthy. +pub const EMERALD: Color32 = Color32::from_rgb(16, 185, 129); +/// Warning. +pub const AMBER: Color32 = Color32::from_rgb(245, 158, 11); +/// Critical / error. +pub const CORAL: Color32 = Color32::from_rgb(239, 68, 68); +/// Window background. +pub const BG_DEEP: Color32 = Color32::from_rgb(15, 23, 42); +/// Card and panel surface. +pub const CARD_SURFACE: Color32 = Color32::from_rgb(30, 41, 59); +/// Borders and inactive elements. +pub const BORDER: Color32 = Color32::from_rgb(71, 85, 105); +/// Secondary text. +pub const MUTED: Color32 = Color32::from_rgb(148, 163, 184); +/// Primary text. +pub const TEXT_WHITE: Color32 = Color32::from_rgb(248, 250, 252); +pub const ACCENT_PURPLE: Color32 = Color32::from_rgb(168, 85, 247); + +/// The darkest surface, for sunken areas: log views and text fields. +/// +/// The terminal front end got this for free — anything it did not paint was the +/// terminal's own background. A window has no such default, so the shade the +/// logs used to sit on has to be named. +pub const BG_SUNKEN: Color32 = Color32::from_rgb(2, 6, 23); + +/// Install the palette on a freshly created context. +/// +/// Called once at startup rather than per frame: egui keeps the style until it +/// is replaced, and rebuilding it every frame would throw away any adjustment +/// made in between. +pub fn apply(ctx: &egui::Context) { + let mut visuals = Visuals::dark(); + + visuals.panel_fill = BG_DEEP; + visuals.window_fill = CARD_SURFACE; + visuals.faint_bg_color = CARD_SURFACE; + visuals.extreme_bg_color = BG_SUNKEN; + visuals.window_stroke = Stroke::new(1.0, BORDER); + visuals.hyperlink_color = CYAN; + visuals.selection.bg_fill = CYAN.linear_multiply(0.35); + visuals.selection.stroke = Stroke::new(1.0, CYAN); + + ctx.set_visuals(visuals); + + ctx.all_styles_mut(|style| { + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(10.0, 5.0); + }); +} + +/// The colour that stands for a severity, everywhere it is shown. +pub fn severity_color(severity: Severity) -> Color32 { + match severity { + Severity::Critical => CORAL, + Severity::Warning => AMBER, + Severity::Info => CYAN, + } +} + +/// The colour a health score should be read in. +/// +/// The thresholds match what the score already means elsewhere in the tool, so +/// a machine the dashboard calls healthy is never painted in the warning colour. +pub fn health_color(score: u8) -> Color32 { + match score { + 80..=100 => EMERALD, + 50..=79 => AMBER, + _ => CORAL, + } +} + +/// A titled surface, the direct equivalent of the terminal front end's bordered +/// block. Everything that was a box on screen stays a box. +pub fn card(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui) -> R) { + egui::Frame::group(ui.style()) + .fill(CARD_SURFACE) + .stroke(Stroke::new(1.0, BORDER)) + .show(ui, |ui| { + // Without this the frame shrinks to its content and a row of cards + // ends up ragged, each one a different width. + ui.set_width(ui.available_width()); + ui.label(RichText::new(title).color(CYAN).strong()); + ui.separator(); + add(ui); + }); +} + +/// A small filled badge: the admin state, the simulation mode, a severity count. +pub fn badge(ui: &mut egui::Ui, text: &str, fill: Color32) { + egui::Frame::NONE + .fill(fill) + .inner_margin(egui::Margin::symmetric(6, 2)) + .corner_radius(3) + .show(ui, |ui| { + ui.label(RichText::new(text).color(BG_DEEP).strong().size(11.0)); + }); +} + +/// Muted secondary text, for everything that labels rather than reports. +pub fn muted(text: impl Into) -> RichText { + RichText::new(text.into()).color(MUTED) +} diff --git a/src/gui/views/dashboard.rs b/src/gui/views/dashboard.rs new file mode 100644 index 0000000..7a10a06 --- /dev/null +++ b/src/gui/views/dashboard.rs @@ -0,0 +1,202 @@ +//! The landing tab: how healthy the machine is, what the last scan found, and +//! the one button that starts another one. + +use crate::app::{App, TAB_SETTINGS}; +use crate::engine::issue::Severity; +use crate::gui::theme; +use crate::modules::ModuleStatus; +use eframe::egui::{self, RichText}; + +pub fn show(ui: &mut egui::Ui, app: &mut App) { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.columns(2, |columns| { + health(&mut columns[0], app); + system(&mut columns[1], app); + }); + + ui.add_space(8.0); + modules(ui, app); + ui.add_space(8.0); + last_action(ui, app); + }); +} + +fn health(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "SYSTEM HEALTH", |ui| { + let score = app.health_score; + + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{score}")) + .color(theme::health_color(score)) + .strong() + .size(46.0), + ); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(theme::muted("out of 100")); + ui.label(RichText::new(verdict(score)).color(theme::health_color(score))); + }); + }); + + ui.add_space(6.0); + ui.add( + egui::ProgressBar::new(score as f32 / 100.0) + .fill(theme::health_color(score)) + .desired_height(8.0), + ); + ui.add_space(10.0); + + let counts = severity_counts(app); + ui.horizontal(|ui| { + theme::badge(ui, &format!("{} critical", counts.0), theme::CORAL); + theme::badge(ui, &format!("{} warnings", counts.1), theme::AMBER); + theme::badge(ui, &format!("{} info", counts.2), theme::CYAN); + }); + + ui.add_space(12.0); + ui.horizontal(|ui| { + let busy = app.is_busy(); + if ui + .add_enabled(!busy, egui::Button::new("Start health scan")) + .clicked() + { + app.start_scan(); + } + let mut dry_run = app.dry_run; + if ui.checkbox(&mut dry_run, "Simulation mode").changed() { + app.toggle_dry_run(); + } + }); + }); +} + +fn verdict(score: u8) -> &'static str { + match score { + 95..=100 => "Healthy", + 80..=94 => "Minor findings", + 50..=79 => "Needs attention", + _ => "Critical", + } +} + +fn severity_counts(app: &App) -> (usize, usize, usize) { + let open = || app.issues.iter().filter(|issue| !issue.is_fixed); + ( + open().filter(|i| i.severity == Severity::Critical).count(), + open().filter(|i| i.severity == Severity::Warning).count(), + open().filter(|i| i.severity == Severity::Info).count(), + ) +} + +fn system(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "SYSTEM", |ui| { + let Some(telemetry) = app.telemetry.as_ref() else { + ui.label(theme::muted("Reading system telemetry...")); + return; + }; + + ui.label(RichText::new(&telemetry.cpu_name).strong()); + ui.label(theme::muted(format!( + "{} {} · {} · {} cores", + telemetry.os_name, telemetry.os_version, telemetry.host_name, telemetry.cpu_count + ))); + ui.add_space(8.0); + + meter( + ui, + "CPU", + telemetry.cpu_usage / 100.0, + &format!("{:.1}%", telemetry.cpu_usage), + ); + meter( + ui, + "Memory", + telemetry.ram_usage_percent / 100.0, + &format!( + "{:.1} / {:.1} GB", + telemetry.ram_used_mb as f32 / 1024.0, + telemetry.ram_total_mb as f32 / 1024.0 + ), + ); + + ui.add_space(8.0); + for disk in &telemetry.disks { + meter( + ui, + &disk.mount_point, + disk.used_percent / 100.0, + &format!( + "{:.0} GB free of {:.0} GB", + disk.available_space_gb, disk.total_space_gb + ), + ); + } + }); +} + +/// A labelled bar. Load is coloured the same way a health score is, so a disk +/// at 95% reads as critical without needing a legend. +fn meter(ui: &mut egui::Ui, label: &str, fraction: f32, value: &str) { + ui.horizontal(|ui| { + ui.label(theme::muted(label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(value); + }); + }); + ui.add( + egui::ProgressBar::new(fraction.clamp(0.0, 1.0)) + .fill(theme::health_color( + 100u8.saturating_sub((fraction * 100.0) as u8), + )) + .desired_height(6.0), + ); + ui.add_space(4.0); +} + +fn modules(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "DIAGNOSTIC MODULES", |ui| { + for (_, name, icon, status) in &app.module_statuses { + ui.horizontal(|ui| { + ui.label(icon.as_str()); + ui.label(name.as_str()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (text, color) = describe(status); + ui.label(RichText::new(text).color(color)); + }); + }); + } + }); +} + +fn describe(status: &ModuleStatus) -> (String, egui::Color32) { + match status { + ModuleStatus::Idle => ("Not scanned".to_string(), theme::MUTED), + ModuleStatus::Scanning => ("Scanning...".to_string(), theme::CYAN), + ModuleStatus::Passed => ("Passed".to_string(), theme::EMERALD), + ModuleStatus::Warning(n) => (format!("{n} warnings"), theme::AMBER), + ModuleStatus::Critical(n) => (format!("{n} critical"), theme::CORAL), + ModuleStatus::Failed(error) => (format!("Failed: {error}"), theme::CORAL), + } +} + +/// The audit trail's most recent entry, and a way to the rest of it. +/// +/// A machine that has never been repaired has no last action, and an empty row +/// saying so is worse than no row at all. +fn last_action(ui: &mut egui::Ui, app: &mut App) { + let Some(entry) = app.audit_entries.last().cloned() else { + return; + }; + + theme::card(ui, "AUDIT TRAIL", |ui| { + ui.horizontal(|ui| { + ui.label(theme::muted("Last action:")); + ui.label(RichText::new(&entry.title).strong()); + ui.label(theme::muted(format!("({})", entry.timestamp))); + }); + if ui.link("Full log, backups & rollback").clicked() { + app.goto_tab(TAB_SETTINGS); + } + }); +} diff --git a/src/gui/views/mod.rs b/src/gui/views/mod.rs new file mode 100644 index 0000000..626b08a --- /dev/null +++ b/src/gui/views/mod.rs @@ -0,0 +1,12 @@ +//! One module per tab, in the order the tab strip shows them. +//! +//! Each exposes a single `show(ui, app)`. They take `&mut App` rather than the +//! handful of fields they read, because in an immediate-mode UI a button *is* +//! the action: the Repair Center's start button calls `App::start_repairs` +//! directly instead of routing a keystroke through the dispatch table first. + +pub mod dashboard; +pub mod repair; +pub mod scanner; +pub mod settings; +pub mod triage; diff --git a/src/gui/views/repair.rs b/src/gui/views/repair.rs new file mode 100644 index 0000000..b248b19 --- /dev/null +++ b/src/gui/views/repair.rs @@ -0,0 +1,116 @@ +//! The repair run: what is being fixed, how far along it is, and the console +//! output of the commands doing it. + +use crate::app::App; +use crate::gui::theme; +use eframe::egui::{self, RichText}; + +pub fn show(ui: &mut egui::Ui, app: &mut App) { + status(ui, app); + ui.add_space(8.0); + console(ui, app); +} + +fn status(ui: &mut egui::Ui, app: &mut App) { + theme::card( + ui, + if app.dry_run { + "SIMULATED REPAIR RUN" + } else { + "REPAIR RUN" + }, + |ui| { + let selected = app + .issues + .iter() + .filter(|i| i.is_selected && !i.is_fixed) + .count(); + + ui.horizontal(|ui| { + if app.is_fixing { + if ui.button("Cancel").clicked() { + app.cancel_current_operation(); + } + } else { + if ui + .add_enabled( + selected > 0, + egui::Button::new(if app.dry_run { + "Simulate repairs" + } else { + "Start repairs" + }), + ) + .clicked() + { + app.start_repairs(); + } + let mut dry_run = app.dry_run; + if ui.checkbox(&mut dry_run, "Simulation mode").changed() { + app.toggle_dry_run(); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::muted(format!("VSS: {}", app.vss_status))); + }); + }); + + ui.add_space(8.0); + + let total = app.total_to_fix.max(1); + let done = app.fixed_count + app.failed_count; + ui.add( + egui::ProgressBar::new(done as f32 / total as f32) + .fill(if app.failed_count > 0 { + theme::AMBER + } else { + theme::EMERALD + }) + .text(format!("{done} / {}", app.total_to_fix)), + ); + + ui.add_space(6.0); + ui.horizontal(|ui| { + theme::badge(ui, &format!("{} repaired", app.fixed_count), theme::EMERALD); + if app.failed_count > 0 { + theme::badge(ui, &format!("{} failed", app.failed_count), theme::CORAL); + } + if !app.current_fix_title.is_empty() { + ui.label(theme::muted(&app.current_fix_title)); + } + }); + + if !app.is_fixing && app.total_to_fix == 0 && selected == 0 { + ui.add_space(6.0); + ui.label(theme::muted( + "Nothing is selected. Pick the findings to repair on the Issue Triage tab.", + )); + } + }, + ); +} + +fn console(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "CONSOLE", |ui| { + egui::ScrollArea::vertical() + .id_salt("repair_console") + .stick_to_bottom(true) + .auto_shrink([false, false]) + .show(ui, |ui| { + for line in &app.repair_console_lines { + // The engine already marks its own outcomes in the text, so + // colouring on those markers keeps a long run scannable + // without the view having to parse anything. + let color = if line.contains("[X]") || line.contains("Failed") { + theme::CORAL + } else if line.contains("[OK]") { + theme::EMERALD + } else { + theme::MUTED + }; + ui.label(RichText::new(line).monospace().color(color)); + } + }); + }); +} diff --git a/src/gui/views/scanner.rs b/src/gui/views/scanner.rs new file mode 100644 index 0000000..ae46862 --- /dev/null +++ b/src/gui/views/scanner.rs @@ -0,0 +1,124 @@ +//! The live scan: one row per diagnostic module, and the log they all write to. + +use crate::app::App; +use crate::gui::theme; +use eframe::egui::{self, RichText}; +use std::time::Duration; + +pub fn show(ui: &mut egui::Ui, app: &mut App) { + controls(ui, app); + ui.add_space(8.0); + + // The log takes whatever the module list leaves over, with a floor, so a + // short window shows a usable amount of both instead of squeezing the log + // to a single line. + let log_height = (ui.available_height() * 0.35).max(140.0); + + egui::Panel::bottom("scan_log") + .exact_size(log_height) + .show_separator_line(false) + .frame(egui::Frame::NONE) + .show(ui, |ui| log(ui, app)); + + modules(ui, app); +} + +fn controls(ui: &mut egui::Ui, app: &mut App) { + ui.horizontal(|ui| { + if app.is_scanning { + if ui.button("Cancel scan").clicked() { + app.cancel_current_operation(); + } + } else if ui.button("Start health scan").clicked() { + app.start_scan(); + } + + ui.add_space(12.0); + + let progress = app.scan_overall_progress as f32 / 100.0; + ui.add( + egui::ProgressBar::new(progress) + .fill(theme::CYAN) + .desired_width(260.0) + .text(format!("{}%", app.scan_overall_progress)), + ); + + if let Some(elapsed) = app.scan_elapsed() { + ui.label(theme::muted(format!( + "{} {}", + if app.is_scanning { "running" } else { "took" }, + format_duration(elapsed) + ))); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::muted(format!("{} issues found", app.issues.len()))); + }); + }); +} + +fn modules(ui: &mut egui::Ui, app: &mut App) { + egui::ScrollArea::vertical() + .id_salt("scan_modules") + .show(ui, |ui| { + for module in &app.module_progress_list { + theme::card(ui, &format!("{} {}", module.icon, module.name), |ui| { + let color = if module.failure.is_some() { + theme::CORAL + } else if module.is_done { + theme::EMERALD + } else { + theme::CYAN + }; + + ui.add( + egui::ProgressBar::new(module.percent as f32 / 100.0) + .fill(color) + .desired_height(8.0), + ); + + ui.horizontal(|ui| { + let step = if module.step.is_empty() { + "Waiting..." + } else { + &module.step + }; + ui.label(RichText::new(step).color(color)); + + // How long the current step has been running is the + // difference between "working" and "hung" for a module + // sitting on a slow DISM call with nothing to report. + if let Some(elapsed) = module.step_elapsed() + && elapsed >= Duration::from_secs(3) + { + ui.label(theme::muted(format!("({})", format_duration(elapsed)))); + } + }); + }); + } + }); +} + +fn log(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "SCAN LOG", |ui| { + egui::ScrollArea::vertical() + .id_salt("scan_log_scroll") + .stick_to_bottom(true) + .auto_shrink([false, false]) + .show(ui, |ui| { + for line in &app.scan_log_messages { + ui.label(RichText::new(line).monospace().color(theme::MUTED)); + } + }); + }); +} + +/// `1m 04s` rather than `64.213s`, which is what a progress readout wants. +pub fn format_duration(duration: Duration) -> String { + let seconds = duration.as_secs(); + if seconds >= 60 { + format!("{}m {:02}s", seconds / 60, seconds % 60) + } else { + format!("{seconds}s") + } +} diff --git a/src/gui/views/settings.rs b/src/gui/views/settings.rs new file mode 100644 index 0000000..30e625c --- /dev/null +++ b/src/gui/views/settings.rs @@ -0,0 +1,180 @@ +//! Settings and the safety surface on one tab: what WinMedic is allowed to do +//! to this machine, and how to undo what it has already done. +//! +//! These were two tabs in the terminal front end and were merged for a reason +//! that still holds — every action the backup list offers is the same kind of +//! "what may this tool touch" decision the settings list is made of. + +use crate::app::{App, SafetyFocus}; +use crate::config::AppConfig; +use crate::gui::theme; +use eframe::egui::{self, RichText}; + +pub fn show(ui: &mut egui::Ui, app: &mut App) { + ui.columns(2, |columns| { + settings(&mut columns[0], app); + safety(&mut columns[1], app); + }); +} + +fn settings(ui: &mut egui::Ui, app: &mut App) { + egui::ScrollArea::vertical() + .id_salt("settings_list") + .auto_shrink([false, false]) + .show(ui, |ui| { + let focused = !app.backups_focused(); + theme::card(ui, &focus_title("SETTINGS", focused), |ui| { + for index in 0..AppConfig::SETTING_COUNT { + let Some((label, value, explanation)) = app.config.setting_row(index) else { + continue; + }; + + // The arrow keys move `selected_setting_index`, so the row + // it points at has to be visible. Without this the keyboard + // moves a selection nothing on screen shows. + let fill = if focused && index == app.selected_setting_index { + theme::BG_SUNKEN + } else { + egui::Color32::TRANSPARENT + }; + + egui::Frame::NONE + .fill(fill) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(label).strong()); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // The two numeric settings open the input + // dialog; the rest are booleans a click + // can flip outright. + let numeric = matches!(index, 4 | 5); + if ui.button(&value).clicked() { + app.safety_focus = SafetyFocus::Settings; + app.selected_setting_index = index; + if numeric { + app.open_setting_input(); + } else { + app.toggle_current_setting(); + } + } + }, + ); + }); + ui.label(theme::muted(explanation)); + }); + ui.add_space(4.0); + } + }); + }); +} + +/// Say which of the two lists the arrow keys are currently driving. +/// +/// The terminal front end put this in the pane titles, and it is needed just as +/// much here: `[B]` moves the arrows between the settings and the snapshots, +/// and a focus the user cannot see is a focus they cannot use. +/// +/// Spelled out in words rather than drawn with the arrow glyphs the terminal +/// front end used: the fonts egui bundles have no glyph for U+25C4 or +/// U+2191/U+2193, so all three reached the screen as empty boxes. +fn focus_title(title: &str, focused: bool) -> String { + if focused { + format!("{title} (arrow keys)") + } else { + format!("{title} ([B] to focus)") + } +} + +fn safety(ui: &mut egui::Ui, app: &mut App) { + egui::ScrollArea::vertical() + .id_salt("safety_panel") + .auto_shrink([false, false]) + .show(ui, |ui| { + backups(ui, app); + ui.add_space(8.0); + restore_points(ui, app); + ui.add_space(8.0); + + theme::card(ui, "LOGS & BACKUPS", |ui| { + ui.label( + RichText::new(app.audit_logger.log_dir().to_string_lossy()) + .monospace() + .size(11.0), + ); + if ui.button("Export HTML report").clicked() { + app.status_message = Some(match app.export_report() { + Ok(path) => format!("Report exported: {}", path.display()), + Err(error) => error, + }); + } + }); + }); +} + +fn backups(ui: &mut egui::Ui, app: &mut App) { + let focused = app.backups_focused(); + theme::card(ui, &focus_title("REGISTRY BACKUPS", focused), |ui| { + let records: Vec<_> = app.backups_newest_first().into_iter().cloned().collect(); + + if records.is_empty() { + ui.label(theme::muted( + "No registry snapshot has been taken yet. WinMedic writes one before it changes a key.", + )); + return; + } + + for (position, record) in records.iter().enumerate() { + let selected = position == app.selected_backup_index; + let response = ui.selectable_label( + selected, + RichText::new(format!("{} {}", record.timestamp, record.description)), + ); + if response.clicked() { + app.safety_focus = SafetyFocus::Backups; + app.selected_backup_index = position; + } + if selected { + ui.label(theme::muted(&record.key_path)); + } + } + + ui.add_space(8.0); + if ui + .add_enabled( + !app.is_restoring, + egui::Button::new("Restore the selected snapshot"), + ) + .clicked() + { + app.request_rollback(); + } + }); +} + +fn restore_points(ui: &mut egui::Ui, app: &mut App) { + theme::card(ui, "SYSTEM RESTORE POINTS", |ui| { + if app.restore_points_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(theme::muted("Asking Windows for its restore points...")); + }); + return; + } + + if app.vss_restore_points.is_empty() { + ui.label(theme::muted("No restore points reported.")); + } else { + for point in &app.vss_restore_points { + ui.label(RichText::new(point).size(12.0)); + } + } + + ui.add_space(8.0); + if ui.button("Refresh").clicked() { + app.refresh_restore_points(); + } + }); +} diff --git a/src/gui/views/triage.rs b/src/gui/views/triage.rs new file mode 100644 index 0000000..4b9f728 --- /dev/null +++ b/src/gui/views/triage.rs @@ -0,0 +1,251 @@ +//! Issue triage: what the scan found, filtered, and which of it to repair. +//! +//! The list decides what a repair run will touch — every issue whose checkbox is +//! ticked — so selection state lives on the issue itself and not on the view. + +use crate::app::App; +use crate::engine::issue::{Issue, Severity}; +use crate::gui::theme; +use eframe::egui::{self, RichText}; + +pub fn show(ui: &mut egui::Ui, app: &mut App) { + filters(ui, app); + ui.add_space(6.0); + + let indices = app.filtered_issue_indices(); + + if app.issues.is_empty() { + empty( + ui, + app, + "No scan has run yet.", + "Start a health scan to see what this machine is dealing with.", + ); + return; + } + if indices.is_empty() { + empty( + ui, + app, + "Nothing matches the current filters.", + "Clear them to see the rest of the findings.", + ); + return; + } + + ui.columns(2, |columns| { + list(&mut columns[0], app, &indices); + detail(&mut columns[1], app, &indices); + }); +} + +fn filters(ui: &mut egui::Ui, app: &mut App) { + ui.horizontal_wrapped(|ui| { + ui.label(theme::muted("Search")); + let field = ui.add( + egui::TextEdit::singleline(&mut app.search_query) + .desired_width(200.0) + .hint_text("title, module or category"), + ); + // `[/]` asks for this field; honouring the request here is what keeps + // the shortcut from having to know anything about widgets. + if app.focus_search { + field.request_focus(); + app.focus_search = false; + } + if field.changed() { + app.clamp_filtered_selection(); + } + + ui.separator(); + + for severity in [Severity::Critical, Severity::Warning, Severity::Info] { + let active = app.severity_filter == Some(severity); + let text = RichText::new(severity.short_label()).color(if active { + theme::BG_DEEP + } else { + theme::severity_color(severity) + }); + if ui.selectable_label(active, text).clicked() { + app.toggle_severity_filter(severity); + } + } + + ui.separator(); + + let module_label = app + .module_filter + .clone() + .unwrap_or_else(|| "All modules".to_string()); + if ui.button(module_label).clicked() { + app.cycle_module_filter(); + } + + if app.has_active_filters() && ui.button("Clear filters").clicked() { + app.clear_filters(); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let selected = app + .issues + .iter() + .filter(|i| i.is_selected && !i.is_fixed) + .count(); + if ui + .add_enabled( + selected > 0 && !app.is_busy(), + egui::Button::new(if app.dry_run { + format!("Simulate {selected} repairs") + } else { + format!("Repair {selected} issues") + }), + ) + .clicked() + { + app.start_repairs(); + } + if ui.button("None").clicked() { + app.deselect_all_issues(); + } + if ui.button("All").clicked() { + app.select_all_issues(); + } + }); + }); +} + +fn list(ui: &mut egui::Ui, app: &mut App, indices: &[usize]) { + egui::ScrollArea::vertical() + .id_salt("issue_list") + .auto_shrink([false, false]) + .show(ui, |ui| { + for (position, &issue_index) in indices.iter().enumerate() { + let highlighted = position == app.selected_filtered_index; + row(ui, app, issue_index, highlighted, position); + } + }); +} + +fn row(ui: &mut egui::Ui, app: &mut App, issue_index: usize, highlighted: bool, position: usize) { + let fill = if highlighted { + theme::CARD_SURFACE + } else { + egui::Color32::TRANSPARENT + }; + + let response = egui::Frame::NONE + .fill(fill) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let issue = &mut app.issues[issue_index]; + + // A fixed issue is history: it cannot be selected for another + // repair run, so the checkbox stops offering. One waiting on a + // restart is in the same position for the same reason, and + // `toggle_select_all_issues` passes over both. + if issue.is_fixed { + ui.label(RichText::new("done").color(theme::EMERALD).size(11.0)); + } else if issue.is_reboot_pending { + ui.label(RichText::new("reboot").color(theme::AMBER).size(11.0)); + } else { + ui.checkbox(&mut issue.is_selected, ""); + } + + let severity = issue.severity; + ui.label( + RichText::new(severity.badge()) + .color(theme::severity_color(severity)) + .strong() + .size(11.0), + ); + ui.label(RichText::new(&issue.title).strong()); + }); + ui.horizontal(|ui| { + ui.add_space(28.0); + let issue = &app.issues[issue_index]; + ui.label(theme::muted(format!( + "{} · {}", + issue.category, issue.module_id + ))); + }); + }) + .response; + + if response.interact(egui::Sense::click()).clicked() { + app.selected_filtered_index = position; + } +} + +fn detail(ui: &mut egui::Ui, app: &mut App, indices: &[usize]) { + let Some(&issue_index) = indices.get(app.selected_filtered_index) else { + return; + }; + let issue: &Issue = &app.issues[issue_index]; + + egui::ScrollArea::vertical() + .id_salt("issue_detail") + .auto_shrink([false, false]) + .show(ui, |ui| { + theme::card(ui, "DETAILS", |ui| { + ui.horizontal_wrapped(|ui| { + theme::badge( + ui, + issue.severity.badge(), + theme::severity_color(issue.severity), + ); + theme::badge(ui, issue.risk_score.badge(), theme::ACCENT_PURPLE); + ui.label(theme::muted(&issue.category)); + }); + ui.add_space(6.0); + ui.label(RichText::new(&issue.title).strong().size(15.0)); + ui.add_space(6.0); + ui.label(&issue.description); + + if !issue.technical_details.is_empty() { + ui.add_space(10.0); + ui.label(theme::muted("Technical detail")); + ui.label( + RichText::new(&issue.technical_details) + .monospace() + .size(11.0), + ); + } + + ui.add_space(10.0); + ui.label(theme::muted("Recommended fix")); + ui.label(&issue.recommended_fix); + + if !issue.fix_steps.is_empty() { + ui.add_space(6.0); + for (number, step) in issue.fix_steps.iter().enumerate() { + ui.label(format!("{}. {}", number + 1, step)); + } + } + + if let Some(error) = issue.fix_error.as_deref() { + ui.add_space(10.0); + ui.label(RichText::new(format!("Repair failed: {error}")).color(theme::CORAL)); + } + }); + }); +} + +fn empty(ui: &mut egui::Ui, app: &mut App, headline: &str, hint: &str) { + ui.vertical_centered(|ui| { + ui.add_space(60.0); + ui.label(RichText::new(headline).size(15.0)); + ui.label(theme::muted(hint)); + ui.add_space(12.0); + if app.issues.is_empty() { + if ui + .add_enabled(!app.is_busy(), egui::Button::new("Start health scan")) + .clicked() + { + app.start_scan(); + } + } else if ui.button("Clear filters").clicked() { + app.clear_filters(); + } + }); +} diff --git a/src/lib.rs b/src/lib.rs index 5c6ae7a..d44e6a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ pub mod app; pub mod config; pub mod engine; +pub mod gui; pub mod modules; pub mod safety; -pub mod ui; pub mod utils; diff --git a/src/main.rs b/src/main.rs index b0ef057..b01f427 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,24 +2,14 @@ // also what the integration tests link against. The binary is a thin front end // over it — declaring `mod app; mod config; …` here again would compile every // module a second time into a separate, untested set of types. -use winmedic::{app, config, engine, safety, ui, utils}; +use winmedic::{config, engine, gui, safety, utils}; use clap::Parser; -use crossterm::cursor::Show; -use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; -use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use std::io::stdout; use std::process::ExitCode; -use std::time::{Duration, Instant}; +use std::sync::Arc; use tokio::sync::mpsc::channel; use tokio_util::sync::CancellationToken; -use app::{App, handle_key}; use config::AppConfig; use engine::exit_code; use engine::reporter::DiagnosticReporter; @@ -31,8 +21,10 @@ use utils::admin::{is_admin, relaunch_as_admin}; #[command( name = "WinMedic", version, - about = "WinMedic - Advanced Windows Self-Healing & Diagnostic TUI in Rust", - long_about = "A high-performance terminal utility that automatically diagnoses, categorizes, and safely repairs Windows errors, update stalls, registry bloat, and network issues. + about = "WinMedic - Advanced Windows Self-Healing & Diagnostic GUI in Rust", + long_about = "A high-performance Windows utility that automatically diagnoses, categorizes, and safely repairs Windows errors, update stalls, registry bloat, and network issues. + +Started with no arguments it opens its desktop window. Every flag below runs headless instead, reporting to the console it was started from. Exit codes (headless mode): 0 no open issues above info level @@ -74,7 +66,7 @@ struct CliArgs { } impl CliArgs { - /// Anything that runs without the interactive TUI. + /// Anything that runs without opening the window. fn is_headless(&self) -> bool { self.scan || self.auto_fix || self.json || self.dry_run || self.output.is_some() } @@ -85,23 +77,26 @@ impl CliArgs { } } -#[tokio::main] -async fn main() -> ExitCode { +fn main() -> ExitCode { let args = CliArgs::parse(); - match run(args).await { + match run(args) { Ok(code) => ExitCode::from(code), Err(err) => { - // The TUI path may have died mid-frame; make sure the terminal is - // usable before the error reaches the user. - restore_terminal(); eprintln!("WinMedic: {}", err); ExitCode::from(exit_code::INTERNAL_ERROR) } } } -async fn run(args: CliArgs) -> Result> { +/// Decide which of the two front ends this invocation wants, and give it a +/// runtime. +/// +/// Deliberately not `#[tokio::main]`. That attribute would put `main` itself +/// inside a runtime, and `run_gui` has to build one it can hold open for the +/// life of the window — building a runtime from inside another one panics. +/// Each branch therefore owns its own. +fn run(args: CliArgs) -> Result> { // A binary replaced by an in-place update is still mapped by the process // that replaced it, so it cannot delete itself; the next start is the first // moment it can go. Best-effort and silent: leftover junk beside the @@ -118,34 +113,13 @@ async fn run(args: CliArgs) -> Result> { } if args.is_headless() { - run_headless(args).await + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(run_headless(args)) } else { - run_tui().await + run_gui() } } -/// Put the terminal back into a usable state. -/// -/// Safe to call more than once and safe to call when the TUI never started — -/// which is what makes it usable from the panic hook. -fn restore_terminal() { - let _ = disable_raw_mode(); - let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture, Show); -} - -/// Install a panic hook that hands the terminal back before printing. -/// -/// Without this a panic inside the draw loop leaves the user with a terminal -/// stuck in raw mode on the alternate screen: no echo, no cursor, no output. -fn install_panic_hook() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - restore_terminal(); - eprintln!("\nWinMedic crashed unexpectedly. The terminal has been restored."); - default_hook(info); - })); -} - // ---------------------------------------------------------------- headless // Scoped rather than a crate-level allow list: the library crate root carries @@ -179,7 +153,12 @@ async fn run_headless(args: CliArgs) -> Result> { } }); - let engine = DiagnosticEngine::new(&config); + // The other half of the seam described in `run_gui`: a run started from the + // command line protects the machine the same way the window does, and this + // is the only other place that says so. Scan and repairs share the one + // engine, so the restore point covers both. + let engine = + Arc::new(DiagnosticEngine::new(&config).with_restore_points(RestorePointService::real())); let (tx, mut rx) = channel::(100); if !quiet { @@ -191,7 +170,9 @@ async fn run_headless(args: CliArgs) -> Result> { } let scan_cancel = cancel.clone(); - let engine_handle = tokio::spawn(async move { engine.run_scan(tx, scan_cancel).await }); + let engine_for_scan = engine.clone(); + let engine_handle = + tokio::spawn(async move { engine_for_scan.run_scan(tx, scan_cancel).await }); let mut scan_cancelled = false; while let Some(evt) = rx.recv().await { @@ -270,11 +251,6 @@ async fn run_headless(args: CliArgs) -> Result> { ); } - // The other half of the seam described in `run_tui`: repairs from the - // command line protect the machine the same way the TUI does, and this - // is the only other place that says so. - let engine = - DiagnosticEngine::new(&config).with_restore_points(RestorePointService::real()); let (fix_tx, mut fix_rx) = channel(100); let options = RepairOptions { create_vss: !args.no_vss && config.create_vss_before_repair, @@ -283,9 +259,10 @@ async fn run_headless(args: CliArgs) -> Result> { }; let fix_cancel = cancel.clone(); + let engine_for_fix = engine.clone(); let mut issues_for_fix = std::mem::take(&mut issues); let fix_handle = tokio::spawn(async move { - let result = engine + let result = engine_for_fix .run_repairs(&mut issues_for_fix, options, fix_tx, fix_cancel) .await; (issues_for_fix, result) @@ -396,56 +373,50 @@ async fn run_headless(args: CliArgs) -> Result> { Ok(code) } -// --------------------------------------------------------------------- TUI - -async fn run_tui() -> Result> { - install_panic_hook(); - - enable_raw_mode()?; - let mut stdout_handle = stdout(); - execute!(stdout_handle, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout_handle); - let mut terminal = Terminal::new(backend)?; - - let mut app = App::new(); - // `App::new` builds an app that cannot touch the desktop. This is the one - // place that wants it to: accepting the update dialog should really open a - // browser here, accepting the elevation dialog should really raise UAC, and - // a repair run should really leave a restore point behind. - app.enable_real_system_actions(); - app.start_update_check(); - let mut last_telemetry_tick = Instant::now(); - - let loop_result = (|| -> Result<(), Box> { - loop { - terminal.draw(|f| { - ui::render_app(f, &app); - })?; - - // `event::read()` only runs when `poll` said something is waiting, - // so short-circuiting keeps it from blocking the draw loop. - if event::poll(Duration::from_millis(40))? - && let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - { - handle_key(&mut app, key.code); - } - - app.process_background_events(); +// --------------------------------------------------------------------- GUI + +fn run_gui() -> Result> { + // WinMedic links into the console subsystem so that its headless mode keeps + // its exit codes and its pipes; see `utils::console` for the whole argument. + // The window has no use for the console that came with it. + utils::console::release_console_if_owned(); + + // The engine is spawned onto tokio from inside the draw loop — + // `App::start_scan` calls `tokio::spawn` directly — so a runtime has to be + // current on the thread eframe draws from. + let runtime = tokio::runtime::Runtime::new()?; + + let mut viewport = eframe::egui::ViewportBuilder::default() + .with_title("WinMedic") + .with_inner_size([1280.0, 820.0]) + .with_min_inner_size([960.0, 640.0]); + + // The same mark the executable carries in its PE resources, so the title + // bar and the taskbar agree with Explorer. A window with no icon still + // works, which is why a decode failure is not worth refusing to start over. + if let Ok(icon) = eframe::icon_data::from_png_bytes(include_bytes!("../assets/logo.png")) { + viewport = viewport.with_icon(icon); + } - if last_telemetry_tick.elapsed() >= Duration::from_secs(1) { - app.refresh_telemetry(); - last_telemetry_tick = Instant::now(); - } + let options = eframe::NativeOptions { + viewport, + ..Default::default() + }; - if app.should_quit { - return Ok(()); - } - } - })(); + let result = { + let _guard = runtime.enter(); + eframe::run_native( + "WinMedic", + options, + Box::new(|cc| Ok(Box::new(gui::WinMedicApp::new(cc)))), + ) + }; - restore_terminal(); - loop_result?; + // Shut down rather than drop: a scan can still be in flight when the window + // closes, and dropping the runtime would block on it. Closing the window + // should not wait for a DISM call that has not come back yet. + runtime.shutdown_background(); + result?; Ok(exit_code::OK) } diff --git a/src/modules/crash_analysis.rs b/src/modules/crash_analysis.rs new file mode 100644 index 0000000..e2cc606 --- /dev/null +++ b/src/modules/crash_analysis.rs @@ -0,0 +1,1541 @@ +use crate::engine::issue::{Issue, RiskScore, Severity}; +use crate::modules::{DiagnosticModule, FixProgress, ModuleConfig, ModuleProgress}; +use crate::utils::cmd::{CommandRunner, SystemCommandRunner}; +use crate::utils::debug_log::DebugTrace; +use chrono::{DateTime, Local}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::Sender; +use tokio::time::sleep; + +const DEFAULT_DUMP_DIR: &str = r"C:\Windows\Minidump"; +/// Kernel dump payloads above this size are treated as full/memory dumps; only +/// the header is parsed for them because scanning megabytes of raw memory for +/// driver names would dominate the scan time. +const DRIVER_SCAN_MAX_BYTES: usize = 16 * 1024 * 1024; +/// Above this many accumulated dump files the triage suggests cleaning up. +const STALE_DUMP_THRESHOLD: usize = 5; + +/// Parsed kernel minidump header information. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MinidumpInfo { + pub bugcheck_code: u32, + pub bugcheck_params: [u64; 4], + /// Best-effort faulting driver (e.g. `nvlddmkm.sys`), if a known + /// third-party driver name is embedded in the dump. + pub faulting_driver: Option, + /// All `.sys` module names found in the dump payload. + pub drivers_seen: Vec, +} + +/// A crash instance reconstructed from a dump file or a BugCheck event. +#[derive(Debug, Clone)] +struct CrashInstance { + bugcheck_code: u32, + when: Option, + source_file: String, +} + +/// Parsed System-log crash event (BugCheck 1001 / Kernel-Power 41). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CrashEventRecord { + pub event_id: u32, + pub bugcheck_code: Option, + pub raw_snippet: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BugcheckCategory { + Memory, + VideoDriver, + Storage, + System, + Driver, +} + +pub struct BugcheckInfo { + pub code: u32, + pub name: &'static str, + pub summary: &'static str, + pub category: BugcheckCategory, +} + +pub struct CrashAnalysisModule { + config: ModuleConfig, + runner: Arc, + dump_dir: PathBuf, +} + +impl CrashAnalysisModule { + pub fn new(config: ModuleConfig) -> Self { + Self::with_runner(config, Arc::new(SystemCommandRunner::new())) + } + + pub fn with_runner(config: ModuleConfig, runner: Arc) -> Self { + Self { + config, + runner, + dump_dir: PathBuf::from(DEFAULT_DUMP_DIR), + } + } + + /// Test seam: like [`Self::with_runner`] but reading dumps from a custom + /// directory so tests never touch the real `C:\Windows\Minidump`. + pub fn with_runner_and_dump_dir( + config: ModuleConfig, + runner: Arc, + dump_dir: impl Into, + ) -> Self { + Self { + config, + runner, + dump_dir: dump_dir.into(), + } + } + + fn lookback_ms(&self) -> u64 { + u64::from(self.config.max_event_log_hours.max(1)) * 3_600_000 + } + + async fn send_progress( + progress_tx: &Option>, + percent: u8, + step: &str, + log: Option<&str>, + ) { + if let Some(tx) = progress_tx { + let _ = tx + .send(ModuleProgress { + module_id: "crash_analysis".to_string(), + progress_percent: percent, + current_step: step.to_string(), + log_message: log.map(|s| s.to_string()), + }) + .await; + } + } + + /// Reads every `*.dmp` in `dump_dir`, parses headers and returns crash + /// instances plus the total file count (for the stale-dump heuristic). + fn collect_dump_crashes(&self) -> (Vec, usize) { + let mut crashes = Vec::new(); + let mut total_files = 0usize; + + let entries = match std::fs::read_dir(&self.dump_dir) { + Ok(entries) => entries, + Err(_) => { + // No admin rights or no crashes at all - not an error, the + // event-log correlation still works. + return (crashes, total_files); + } + }; + + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("dmp")) + }) + .collect(); + files.sort(); + + for path in files { + total_files += 1; + let modified = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .map(|t| { + DateTime::::from(t) + .format("%Y-%m-%d %H:%M") + .to_string() + }); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown.dmp") + .to_string(); + + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let Some(info) = parse_minidump_header(&bytes) else { + continue; + }; + + crashes.push(CrashInstance { + bugcheck_code: info.bugcheck_code, + when: modified, + source_file: file_name, + }); + } + + (crashes, total_files) + } + + fn count_user_mode_dumps(&self) -> usize { + let Some(local) = std::env::var_os("LOCALAPPDATA") else { + return 0; + }; + let dir = Path::new(&local).join("CrashDumps"); + std::fs::read_dir(&dir) + .map(|entries| { + entries + .flatten() + .filter(|e| { + e.path() + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| x.eq_ignore_ascii_case("dmp")) + }) + .count() + }) + .unwrap_or(0) + } +} + +#[async_trait::async_trait] +impl DiagnosticModule for CrashAnalysisModule { + fn id(&self) -> &'static str { + "crash_analysis" + } + + fn name(&self) -> &'static str { + "Crash Dump & BSOD Analyzer" + } + + fn description(&self) -> &'static str { + "Parses kernel minidumps and BugCheck events to identify stop codes, faulting drivers and crash frequency" + } + + fn icon(&self) -> &'static str { + "[DMP]" + } + + async fn scan( + &self, + progress_tx: Option>, + ) -> Result, String> { + let mut issues = Vec::new(); + let dbg = DebugTrace::scan(self.id(), progress_tx.clone(), self.config.verbose_logging); + let window_hours = self.config.max_event_log_hours.max(1); + + // Step 1: read minidump files + Self::send_progress( + &progress_tx, + 15, + &format!("Reading kernel minidumps from {}...", DEFAULT_DUMP_DIR), + Some("Parsing dump headers for stop codes and drivers..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + dbg.section("Minidump Directory").await; + dbg.kv("dump_dir", DEFAULT_DUMP_DIR).await; + dbg.kv("lookback_hours", window_hours.to_string()).await; + + let (dump_crashes, total_dump_files) = self.collect_dump_crashes(); + let user_dump_count = self.count_user_mode_dumps(); + dbg.kv("kernel_dumps", total_dump_files.to_string()).await; + dbg.kv("user_mode_dumps", user_dump_count.to_string()).await; + + // Step 2: correlate System event log (BugCheck 1001, Kernel-Power 41) + Self::send_progress( + &progress_tx, + 50, + "Correlating BugCheck (1001) & Kernel-Power (41) events...", + Some("Querying System log via wevtutil..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + let query = format!( + r#"*[System[(Provider[@Name='BugCheck' or @Name='Microsoft-Windows-Kernel-Power']) and (EventID=1001 or EventID=41) and TimeCreated[timediff(@AutoGeneratedDate) <= {}]]]"#, + self.lookback_ms() + ); + + let evt_out = dbg + .run( + &self.runner, + "wevtutil.exe", + &["qe", "System", &query, "/f:text", "/c:200"], + Duration::from_secs(15), + ) + .await; + + let raw_stdout = match evt_out { + Ok(ref out) => out.stdout.trim().to_string(), + Err(ref err) => { + dbg.warn(format!("Failed to query crash event logs: {}", err)) + .await; + String::new() + } + }; + + let events = parse_crash_events(&raw_stdout); + let bugcheck_events: Vec<&CrashEventRecord> = events + .iter() + .filter(|e| e.event_id == 1001 && e.bugcheck_code.is_some()) + .collect(); + let kernel_power_41: usize = events.iter().filter(|e| e.event_id == 41).count(); + dbg.kv("bugcheck_events", bugcheck_events.len().to_string()) + .await; + dbg.kv("kernel_power_41", kernel_power_41.to_string()).await; + + // Merge dump + event crash instances + let mut crashes: Vec = dump_crashes; + for e in &bugcheck_events { + crashes.push(CrashInstance { + bugcheck_code: e.bugcheck_code.unwrap_or(0), + when: None, + source_file: "Event 1001".to_string(), + }); + } + + if crashes.is_empty() && kernel_power_41 == 0 { + Self::send_progress( + &progress_tx, + 90, + "No BSOD or unexpected-shutdown records found", + Some("System is crash-free within the analysis window."), + ) + .await; + Self::send_progress(&progress_tx, 100, "Crash analysis complete", None).await; + return Ok(issues); + } + + // Step 3: classify crashes + Self::send_progress( + &progress_tx, + 80, + "Classifying stop codes, faulting drivers & crash frequency...", + Some("Mapping bugcheck codes to root-cause categories..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + // Driver attribution: prefer the faulting driver parsed from each dump, + // fall back to the video stack for TDR stop codes. + let mut per_driver: BTreeMap> = BTreeMap::new(); + let memory_codes = [0x1A, 0x50, 0x2E, 0x77, 0xC2, 0x19]; + let video_codes = [0x116, 0x117]; + + // We need owned copies of driver names for map keys; the dump payload + // scan already produced them per crash, so re-derive via the dump files + // referenced by each instance. + let mut driver_by_source: BTreeMap = BTreeMap::new(); + for path in dump_dir_dmp_files(&self.dump_dir) { + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown.dmp") + .to_string(); + if let Ok(bytes) = std::fs::read(&path) + && let Some(info) = parse_minidump_header(&bytes) + && let Some(driver) = info.faulting_driver + { + driver_by_source.insert(file_name, driver); + } + } + + let mut memory_crashes = Vec::new(); + let mut video_crashes = Vec::new(); + let mut generic_crashes = Vec::new(); + + for crash in &crashes { + if video_codes.contains(&crash.bugcheck_code) { + video_crashes.push(crash); + } else if memory_codes.contains(&crash.bugcheck_code) { + memory_crashes.push(crash); + } else if let Some(driver) = driver_by_source.get(&crash.source_file) { + per_driver.entry(driver.clone()).or_default().push(crash); + } else { + generic_crashes.push(crash); + } + } + + // 1. Faulting driver identified (e.g. nvlddmkm.sys) + for (driver, instances) in &per_driver { + let count = instances.len(); + let severity = if count >= 2 { + Severity::Critical + } else { + Severity::Warning + }; + let stop_codes: BTreeSet = instances + .iter() + .map(|c| format!("0x{:08X}", c.bugcheck_code)) + .collect(); + let dates: Vec<&str> = instances.iter().filter_map(|c| c.when.as_deref()).collect(); + let component = describe_driver_component(driver); + + issues.push(Issue::new( + "crash_driver_fault", + self.id(), + format!("BSOD caused by driver {} ({} crash(es))", driver, count), + "Hardware & Stability", + severity, + RiskScore::Medium, + format!( + "Crash dumps directly implicate the {} driver {}. Repeated stop codes of this class typically follow a driver update gone wrong, a vendor-package conflict (e.g. GeForce/Adrenalin clean-install leftovers) or failing hardware behind the driver.", + component, driver + ), + format!( + "Faulting driver: {}\nStop code(s): {}\nCrashes: {}\nMost recent: {}\nDump files: {}", + driver, + stop_codes.into_iter().collect::>().join(", "), + count, + dates.last().copied().unwrap_or("unknown"), + instances.iter().map(|c| c.source_file.as_str()).take(4).collect::>().join(", "), + ), + "Roll back or clean-reinstall the implicated driver (DDU + latest vendor package)", + vec![ + format!("Open Device Manager and roll back the {} driver", driver), + format!( + "Perform a clean reinstall of the {} driver with DDU, then install the latest stable vendor package", + component + ), + "Verify the crash frequency drops in the days after the swap".to_string(), + ], + )); + } + + // 2. GPU / TDR crashes + if !video_crashes.is_empty() { + let count = video_crashes.len(); + let video_driver = video_crashes + .iter() + .find_map(|c| driver_by_source.get(&c.source_file).cloned()) + .or_else(|| per_driver.keys().next().cloned()) + .unwrap_or_else(|| "unknown".to_string()); + issues.push(Issue::new( + "crash_video_tdr", + self.id(), + format!("GPU driver timeout / TDR crash(es) ({} events)", count), + "Hardware & Stability", + Severity::Critical, + RiskScore::Medium, + "VIDEO_TDR_FAILURE / VIDEO_TDR_TIMEOUT_DETECTED stop codes: the GPU stopped responding and the display driver was reset. Typical causes are an unstable GPU driver, aggressive factory overclock, overheating or an undersized PSU under load spikes.", + format!( + "Stop code(s): 0x116 / 0x117\nCrashes: {}\nImplicated display driver: {}\nDump files: {}", + count, + video_driver, + video_crashes.iter().map(|c| c.source_file.as_str()).take(4).collect::>().join(", "), + ), + "Clean-reinstall the GPU driver and check GPU thermals", + vec![ + "Clean-reinstall the GPU driver (DDU, then latest stable vendor package)".to_string(), + "Check GPU temperatures under load and improve case airflow if needed".to_string(), + "If crashes persist, test with a known-stable driver branch or reduce core clock offsets".to_string(), + ], + )); + } + + // 3. Memory-class stop codes without a clear driver + if !memory_crashes.is_empty() { + let count = memory_crashes.len(); + issues.push(Issue::new( + "crash_memory_bugcheck", + self.id(), + format!("Memory-related BSOD stop code(s) ({} crashes)", count), + "Hardware & Stability", + Severity::Critical, + RiskScore::Medium, + "MEMORY_MANAGEMENT / PAGE_FAULT_IN_NONPAGED_AREA class stop codes point to defective or unstable RAM (XMP/EXPO timing instability, failing DIMM) or corrupted page tables.", + format!( + "Crashes: {}\nDump files: {}", + count, + memory_crashes.iter().map(|c| c.source_file.as_str()).take(4).collect::>().join(", "), + ), + "Schedule Windows Memory Diagnostic (mdsched.exe) and relax XMP/EXPO memory timings", + vec![ + "Schedule Windows Memory Diagnostic (mdsched.exe) for the next reboot".to_string(), + "Lower XMP/EXPO memory frequency or increase DRAM voltage in BIOS".to_string(), + "If errors persist, test DIMMs individually to isolate the failing module".to_string(), + ], + )); + } + + // 4. Remaining crashes without driver attribution + if !generic_crashes.is_empty() { + let count = generic_crashes.len(); + let severity = if count >= 3 { + Severity::Critical + } else { + Severity::Warning + }; + let code_counts: BTreeMap = generic_crashes + .iter() + .map(|c| (format!("0x{:08X}", c.bugcheck_code), ())) + .fold(BTreeMap::new(), |mut acc, (code, ())| { + *acc.entry(code).or_insert(0) += 1; + acc + }); + let details = code_counts + .iter() + .map(|(code, cnt)| { + let info = bugcheck_info( + u32::from_str_radix(code.trim_start_matches("0x"), 16).unwrap_or(0), + ); + match info { + Some(i) => format!("{} x{} ({})", code, cnt, i.name), + None => format!("{} x{}", code, cnt), + } + }) + .collect::>() + .join("\n"); + + issues.push(Issue::new( + "crash_bugcheck_history", + self.id(), + format!("Recurring BSOD history ({} crashes)", count), + "Hardware & Stability", + severity, + RiskScore::Low, + "Bugchecks were recorded whose stop codes do not isolate a single driver or memory subsystem. Driver conflicts after Windows/driver updates are the most common root cause of this pattern.", + format!("Stop code distribution:\n{}", details), + "Review recently updated drivers and run system file verification (sfc /scannow)", + vec![ + "Review drivers updated shortly before the first crash and roll suspect updates back".to_string(), + "Run 'sfc /scannow' and 'DISM /Online /Cleanup-Image /RestoreHealth'".to_string(), + ], + )); + } + + // 5. Kernel-Power 41 events beyond explainable bugchecks + let bugcheck_total = crashes.len(); + if kernel_power_41 > bugcheck_total { + let unexplained = kernel_power_41 - bugcheck_total; + issues.push(Issue::new( + "crash_unexpected_shutdown", + self.id(), + format!( + "{} unexpected shutdown(s) without a bugcheck", + unexplained + ), + "Hardware & Stability", + Severity::Warning, + RiskScore::Low, + "Kernel-Power Event 41 was logged without a corresponding crash dump: the system lost power or froze hard before Windows could write a bugcheck. This points to PSU instability, overheating with emergency shutdown, a stuck system image or a held power button.", + format!( + "Kernel-Power 41 events: {}\nBugcheck-backed crashes: {}\nUnexplained: {}", + kernel_power_41, bugcheck_total, unexplained + ), + "Check PSU capacity/cabling, CPU & GPU thermals and Windows reliability history", + vec![ + "Check CPU/GPU temperatures under load and clean dust from fans/radiators".to_string(), + "Verify the PSU is sized for peak system load and cables are fully seated".to_string(), + "Review Event Viewer reliability history around the shutdown timestamps".to_string(), + ], + )); + } + + // 6. Dump accumulation + if total_dump_files > STALE_DUMP_THRESHOLD || user_dump_count > 10 { + issues.push(Issue::new( + "crash_stale_dumps", + self.id(), + format!( + "{} crash dump(s) accumulated on disk", + total_dump_files + user_dump_count + ), + "System Cleanup", + Severity::Info, + RiskScore::Low, + "A large number of crash dumps has accumulated. Each kernel minidump is up to a few megabytes; once analysed they can be safely deleted to reclaim disk space.", + format!( + "Kernel minidumps in {}: {}\nUser-mode dumps in %LOCALAPPDATA%\\CrashDumps: {}", + DEFAULT_DUMP_DIR, total_dump_files, user_dump_count + ), + "Delete analysed crash dump files", + vec!["Remove outdated minidump and user-mode crash dump files".to_string()], + )); + } + + Self::send_progress(&progress_tx, 100, "Crash analysis complete", None).await; + + Ok(issues) + } + + async fn fix( + &self, + issue_id: &str, + progress_tx: Option>, + ) -> Result { + let dbg = DebugTrace::fix(self.id(), progress_tx.clone(), self.config.verbose_logging); + match issue_id { + "crash_driver_fault" | "crash_video_tdr" => { + if let Some(ref tx) = progress_tx { + let _ = tx + .send(FixProgress { + issue_id: issue_id.to_string(), + step_description: "Opening Device Manager for driver rollback...".to_string(), + is_success: true, + error: None, + console_line: Some("cmd.exe /c start devmgmt.msc".to_string()), + }) + .await; + } + + let open_res = dbg + .run( + &self.runner, + "cmd.exe", + &["/c", "start", "", "devmgmt.msc"], + Duration::from_secs(10), + ) + .await; + + match open_res { + Ok(out) if out.success => Ok( + "Device Manager opened. Roll back or clean-reinstall the implicated driver as outlined in the fix steps." + .to_string(), + ), + _ => Ok( + "Manual action required: open Device Manager, roll back the implicated display/driver, then clean-reinstall the latest vendor package (DDU recommended)." + .to_string(), + ), + } + } + "crash_memory_bugcheck" => { + if let Some(ref tx) = progress_tx { + let _ = tx + .send(FixProgress { + issue_id: issue_id.to_string(), + step_description: "Scheduling Windows Memory Diagnostic tool...".to_string(), + is_success: true, + error: None, + console_line: Some("mdsched.exe".to_string()), + }) + .await; + } + + let sched_res = self + .runner + .run("mdsched.exe", &[], Duration::from_secs(5)) + .await; + + let sched_msg = match sched_res { + Ok(out) if out.success => "Windows Memory Diagnostic (mdsched.exe) launched.", + _ => "Windows Memory Diagnostic recommendation recorded.", + }; + + Ok(format!( + "{} The memory test runs during the next reboot; also check XMP/EXPO settings in BIOS.", + sched_msg + )) + } + "crash_stale_dumps" => { + if let Some(ref tx) = progress_tx { + let _ = tx + .send(FixProgress { + issue_id: issue_id.to_string(), + step_description: "Deleting analysed crash dump files...".to_string(), + is_success: true, + error: None, + console_line: Some(format!( + "powershell.exe Remove-Item '{}\\*.dmp'", + DEFAULT_DUMP_DIR + )), + }) + .await; + } + + let remove_cmd = format!( + "Remove-Item -Path '{}\\*.dmp' -Force -ErrorAction SilentlyContinue", + DEFAULT_DUMP_DIR + ); + let res = dbg + .run( + &self.runner, + "powershell.exe", + &["-NoProfile", "-Command", &remove_cmd], + Duration::from_secs(30), + ) + .await; + + let user_dir_cmd = + "Remove-Item -Path (Join-Path $env:LOCALAPPDATA 'CrashDumps\\*.dmp') -Force -ErrorAction SilentlyContinue"; + let _ = dbg + .run( + &self.runner, + "powershell.exe", + &["-NoProfile", "-Command", user_dir_cmd], + Duration::from_secs(30), + ) + .await; + + match res { + Ok(out) if out.success => Ok("Analysed crash dump files deleted.".to_string()), + _ => Ok("Crash dump cleanup ran; individual unreadable files may remain.".to_string()), + } + } + "crash_unexpected_shutdown" | "crash_bugcheck_history" => { + Ok("Advisory finding recorded in the audit trail. Follow the fix steps: check thermals/PSU (Event 41) and roll back recently updated drivers (bugcheck history).".to_string()) + } + _ => Err(format!("Unknown crash analysis issue id: {}", issue_id)), + } + } +} + +fn dump_dir_dmp_files(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("dmp")) + }) + .collect(); + files.sort(); + files +} + +/// Parses the kernel dump header (`PAGEDUMP` / `PAGEDU64` signatures) to +/// extract the bugcheck code, its four parameters and embedded driver names. +/// +/// Header layout (documented `DUMP_HEADER` / `DUMP_HEADER64`): +/// 32-bit: signature `PAGE`+`DUMP`, BugCheckCode at 0x28, params (u32) at 0x2C. +/// 64-bit: signature `PAGE`+`DU64`, BugCheckCode at 0x38, params (u64) at 0x40. +pub fn parse_minidump_header(bytes: &[u8]) -> Option { + if bytes.len() < 8 || &bytes[0..4] != b"PAGE" { + return None; + } + + let mut info = MinidumpInfo::default(); + let (code_off, param_off, param_u64): (usize, usize, bool) = match &bytes[4..8] { + b"DU64" => (0x38, 0x40, true), + b"DUMP" => (0x28, 0x2C, false), + _ => return None, + }; + + let read_le = |off: usize, len: usize| -> Option { + if off + len > bytes.len() { + return None; + } + let mut buf = [0u8; 8]; + buf[..len].copy_from_slice(&bytes[off..off + len]); + Some(u64::from_le_bytes(buf)) + }; + + info.bugcheck_code = read_le(code_off, 4)? as u32; + for (i, slot) in info.bugcheck_params.iter_mut().enumerate() { + *slot = if param_u64 { + read_le(param_off + i * 8, 8)? + } else { + read_le(param_off + i * 4, 4)? + }; + } + + if bytes.len() <= DRIVER_SCAN_MAX_BYTES { + let drivers = extract_driver_names(bytes); + info.faulting_driver = pick_faulting_driver(&drivers); + info.drivers_seen = drivers; + } + + Some(info) +} + +/// Collects `.sys` module names embedded in the dump payload, both as ASCII +/// and as UTF-16LE (the format the kernel module list uses). +pub fn extract_driver_names(bytes: &[u8]) -> Vec { + let mut found: BTreeSet = BTreeSet::new(); + + // ASCII runs + let mut current = String::new(); + for &b in bytes { + if b.is_ascii_graphic() { + current.push(b as char); + } else { + flush_driver_token(¤t, &mut found); + current.clear(); + } + } + flush_driver_token(¤t, &mut found); + + // UTF-16LE runs. Manual stepping instead of chunks_exact(2): the exact + // API differs between toolchain versions and index pairs sidestep that. + let mut current = String::new(); + let pairs = bytes.len() / 2 * 2; + for i in (0..pairs).step_by(2) { + let unit = u16::from_le_bytes([bytes[i], bytes[i + 1]]); + let c = u32::from(unit); + if (0x20..=0x7E).contains(&c) { + current.push(char::from_u32(c).unwrap_or('?')); + } else { + flush_driver_token(¤t, &mut found); + current.clear(); + } + } + flush_driver_token(¤t, &mut found); + + found.into_iter().collect() +} + +fn flush_driver_token(token: &str, out: &mut BTreeSet) { + if token.len() >= 6 && token.len() <= 48 && token.to_ascii_lowercase().ends_with(".sys") { + out.insert(token.to_ascii_lowercase()); + } +} + +/// Drivers with a strong track record of causing BSODs; if one of these is +/// embedded in the dump we name it as the faulting module. +const KNOWN_TROUBLE_DRIVERS: &[&str] = &[ + "nvlddmkm.sys", + "nvlddmkm", + "amdkmdag.sys", + "amdkmdap.sys", + "atikmdag.sys", + "atikmpag.sys", + "igdkmd64.sys", + "igdkmd32.sys", + "nvme.sys", + "storahci.sys", + "stornvme.sys", + "rt640x64.sys", + "rt640x86.sys", + "e1d.sys", + "e1g6032e.sys", + "netwtw06.sys", + "netwtw04.sys", + "athw8x.sys", + "athwnx.sys", + "bcmwl63a.sys", + "wdiwifi.sys", + "dxgkrnl.sys", + "dxgmms2.sys", + "tcpip.sys", + "ntfs.sys", + "fltmgr.sys", + "aswndsys.sys", + "aswsp.sys", +]; + +fn pick_faulting_driver(drivers: &[String]) -> Option { + for known in KNOWN_TROUBLE_DRIVERS { + if let Some(hit) = drivers.iter().find(|d| d == known || d.ends_with(known)) { + return Some(hit.clone()); + } + } + None +} + +fn describe_driver_component(driver: &str) -> &'static str { + let d = driver.to_ascii_lowercase(); + if d.starts_with("nvlddmkm") { + "NVIDIA display" + } else if d.starts_with("amd") || d.starts_with("atik") { + "AMD display" + } else if d.starts_with("igdkmd") { + "Intel graphics" + } else if d.contains("nvme") + || d.contains("storahci") + || d.contains("stornvme") + || d.contains("ntfs") + { + "storage" + } else if d.contains("rt640") + || d.starts_with("e1") + || d.contains("netwtw") + || d.contains("athw") + || d.contains("wifi") + { + "network" + } else if d.contains("dxg") { + "DirectX graphics kernel" + } else { + "kernel" + } +} + +/// Human-readable information for the most common bugcheck codes. +pub fn bugcheck_info(code: u32) -> Option { + let info = match code { + 0x03 => ( + "INVALID_AFFINITY_SET", + "Illegal processor affinity was requested", + BugcheckCategory::System, + ), + 0x0A => ( + "IRQL_NOT_LESS_OR_EQUAL", + "A kernel-mode driver accessed paged memory at an invalid IRQL", + BugcheckCategory::Driver, + ), + 0x1A => ( + "MEMORY_MANAGEMENT", + "Severe memory management failure, typically defective RAM or corrupted page tables", + BugcheckCategory::Memory, + ), + 0x18 => ( + "REFERENCE_BY_POINTER", + "Reference-counting inconsistency on a kernel object", + BugcheckCategory::System, + ), + 0x19 => ( + "BAD_POOL_HEADER", + "Corrupted kernel pool header, often caused by a buggy driver or RAM", + BugcheckCategory::Memory, + ), + 0x1E => ( + "KMODE_EXCEPTION_NOT_HANDLED", + "An unhandled exception occurred in kernel mode", + BugcheckCategory::Driver, + ), + 0x24 => ( + "NTFS_FILE_SYSTEM", + "NTFS volume corruption or a faulty storage stack", + BugcheckCategory::Storage, + ), + 0x2E => ( + "DATA_BUS_ERROR", + "Parity error on the system bus, typically failing RAM or motherboard", + BugcheckCategory::Memory, + ), + 0x3B => ( + "SYSTEM_SERVICE_EXCEPTION", + "An exception occurred while executing a system service", + BugcheckCategory::Driver, + ), + 0x44 => ( + "MULTIPLE_IRP_COMPLETE_REQUESTS", + "A driver completed the same I/O request packet twice", + BugcheckCategory::Driver, + ), + 0x50 => ( + "PAGE_FAULT_IN_NONPAGED_AREA", + "Invalid memory reference in non-paged RAM, classically a RAM or driver fault", + BugcheckCategory::Memory, + ), + 0x77 => ( + "KERNEL_STACK_INPAGE_ERROR", + "A kernel stack page could not be read from disk, often a failing drive", + BugcheckCategory::Storage, + ), + 0x7A => ( + "KERNEL_DATA_INPAGE_ERROR", + "Kernel data could not be paged in from disk - drive or controller fault", + BugcheckCategory::Storage, + ), + 0x7F => ( + "UNEXPECTED_KERNEL_MODE_TRAP", + "CPU trap in kernel mode, often hardware (RAM/CPU) or overclock instability", + BugcheckCategory::Memory, + ), + 0x9F => ( + "DRIVER_POWER_STATE_FAILURE", + "A driver stalled during a power transition (sleep/resume)", + BugcheckCategory::Driver, + ), + 0xA0 => ( + "INTERNAL_POWER_ERROR", + "Power policy failure, commonly GPU power management on laptops", + BugcheckCategory::VideoDriver, + ), + 0xC2 => ( + "BAD_POOL_CALLER", + "A driver made an invalid pool request, often RAM or driver corruption", + BugcheckCategory::Memory, + ), + 0xC4 => ( + "DRIVER_VERIFIER_DETECTED_VIOLATION", + "Driver Verifier caught a driver breaking kernel rules", + BugcheckCategory::Driver, + ), + 0xC5 => ( + "DRIVER_CORRUPTED_EXPOOL", + "A driver corrupted the executive pool", + BugcheckCategory::Driver, + ), + 0xC9 => ( + "DRIVER_VERIFIER_IOMANAGER_VIOLATION", + "Driver Verifier caught an I/O manager violation", + BugcheckCategory::Driver, + ), + 0xD1 => ( + "DRIVER_IRQL_NOT_LESS_OR_EQUAL", + "A driver accessed invalid memory at too high an IRQL", + BugcheckCategory::Driver, + ), + 0xEA => ( + "THREAD_STUCK_IN_DEVICE_DRIVER", + "A display driver thread stopped responding, GPU/driver fault", + BugcheckCategory::VideoDriver, + ), + 0xF5 => ( + "FLTMGR_FILE_SYSTEM", + "Filter Manager filesystem corruption", + BugcheckCategory::Storage, + ), + 0xFC => ( + "ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY", + "Code execution from non-executable memory, usually a buggy driver", + BugcheckCategory::Driver, + ), + 0x109 => ( + "CRITICAL_STRUCTURE_CORRUPTION", + "Kernel code/data was modified, often by buggy drivers or malware", + BugcheckCategory::System, + ), + 0x116 => ( + "VIDEO_TDR_FAILURE", + "The display driver failed to reset in time (Timeout Detection & Recovery)", + BugcheckCategory::VideoDriver, + ), + 0x117 => ( + "VIDEO_TDR_TIMEOUT_DETECTED", + "The GPU stopped responding and the driver recovered or bugchecked", + BugcheckCategory::VideoDriver, + ), + 0x124 => ( + "WHEA_UNCORRECTABLE_ERROR", + "Fatal hardware error reported by the CPU/platform (WHEA)", + BugcheckCategory::System, + ), + 0x133 => ( + "DPC_WATCHDOG_VIOLATION", + "A deferred procedure call ran too long, typically storage/GPU drivers or SSD firmware", + BugcheckCategory::Driver, + ), + 0x139 => ( + "KERNEL_SECURITY_CHECK_FAILURE", + "Kernel stack buffer overrun or security check failed, often driver or RAM related", + BugcheckCategory::System, + ), + 0x13A => ( + "KERNEL_MODE_HEAP_CORRUPTION", + "Kernel heap corruption, commonly driver or RAM faults", + BugcheckCategory::Memory, + ), + 0x154 => ( + "UNEXPECTED_STORE_EXCEPTION", + "Memory compression store error, frequently a failing SSD or RAM", + BugcheckCategory::Storage, + ), + 0x15F => ( + "CONNECTED_STANDBY_WATCHDOG_TIMEOUT", + "Modern Standby watchdog expired during a sleep transition", + BugcheckCategory::Driver, + ), + 0x1000007E => ( + "SYSTEM_THREAD_EXCEPTION_NOT_HANDLED_M", + "A system thread raised an unhandled exception", + BugcheckCategory::Driver, + ), + 0x100000D1 => ( + "DRIVER_IRQL_NOT_LESS_OR_EQUAL_M", + "DRIVER_IRQL_NOT_LESS_OR_EQUAL with modified parameters", + BugcheckCategory::Driver, + ), + _ => return None, + }; + Some(BugcheckInfo { + code, + name: info.0, + summary: info.1, + category: info.2, + }) +} + +/// Parses raw `wevtutil /f:text` System-log output into crash event records +/// (BugCheck Event 1001 with stop code, Kernel-Power Event 41). +pub fn parse_crash_events(raw_output: &str) -> Vec { + let mut records = Vec::new(); + if raw_output.trim().is_empty() { + return records; + } + + let mut chunks: Vec = Vec::new(); + let mut current: Vec<&str> = Vec::new(); + for line in raw_output.lines() { + if (line.starts_with("Event[") || line.starts_with("Ereignis[")) && !current.is_empty() { + chunks.push(current.join("\n")); + current.clear(); + } + current.push(line); + } + if !current.is_empty() { + chunks.push(current.join("\n")); + } + + for chunk in &chunks { + let lower = chunk.to_lowercase(); + let is_bugcheck = lower.contains("bugcheck") || lower.contains("prüfpunkt"); + let is_kernel_power = lower.contains("kernel-power") || lower.contains("kernel-energie"); + if !is_bugcheck && !is_kernel_power { + continue; + } + + let mut event_id = 0u32; + let mut bugcheck_code = None; + + for line in chunk.lines() { + let trimmed = line.trim(); + for prefix in ["Event ID:", "EventID:", "Ereignis-ID:"] { + if let Some(pos) = trimmed.find(prefix) { + let val = trimmed[pos + prefix.len()..].trim(); + if let Ok(id) = val.parse::() { + event_id = id; + } + } + } + // XML rendering: 1001 + if let Some(rest) = trimmed.strip_prefix("") + && let Some(end) = rest.find('<') + && let Ok(id) = rest[..end].trim().parse::() + { + event_id = id; + } + if let (None, Some(code)) = (bugcheck_code, extract_hex_code(trimmed)) { + bugcheck_code = Some(code); + } + } + + // Kernel-Power also logs events other than 41; only keep 41 here. + if is_kernel_power && event_id == 0 { + event_id = 41; + } + if is_kernel_power && event_id != 41 { + continue; + } + if is_bugcheck && event_id == 0 { + event_id = 1001; + } + + records.push(CrashEventRecord { + event_id, + bugcheck_code, + raw_snippet: chunk.trim().to_string(), + }); + } + + records +} + +/// Extracts the first `0x........` (8 hex digits) stop code on a line. +fn extract_hex_code(line: &str) -> Option { + let line = line.trim(); + let lower = line.to_lowercase(); + let mut search_from = 0usize; + while let Some(pos) = lower[search_from..].find("0x") { + let start = search_from + pos + 2; + let hex: String = lower[start..] + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .take(8) + .collect(); + if hex.len() == 8 { + return u32::from_str_radix(&hex, 16).ok(); + } + search_from = start; + if search_from >= lower.len() { + break; + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::cmd::{CmdOutput, MockCommandRunner}; + + fn empty_event_response(mock: &MockCommandRunner) { + mock.add_response("wevtutil.exe", CmdOutput::ok("")); + } + + /// Builds a synthetic 64-bit kernel dump with the given bugcheck code and + /// an optional embedded ASCII driver name. + fn synth_dump64(code: u32, extra: &[u8]) -> Vec { + let mut bytes = vec![0u8; 0x60]; + bytes[0..8].copy_from_slice(b"PAGEDU64"); + bytes[0x38..0x3C].copy_from_slice(&code.to_le_bytes()); + bytes[0x40..0x48].copy_from_slice(&0x1111111111111111u64.to_le_bytes()); + bytes[0x48..0x50].copy_from_slice(&0x2222222222222222u64.to_le_bytes()); + bytes[0x50..0x58].copy_from_slice(&0x3333333333333333u64.to_le_bytes()); + bytes[0x58..0x60].copy_from_slice(&0x4444444444444444u64.to_le_bytes()); + bytes.extend_from_slice(b"\x00\x00"); + bytes.extend_from_slice(extra); + bytes + } + + fn temp_dump_dir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("winmedic_dmp_test_{}_{}", tag, std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("failed to create temp dump dir"); + dir + } + + fn write_dump(dir: &Path, name: &str, bytes: &[u8]) { + std::fs::write(dir.join(name), bytes).expect("failed to write dump"); + } + + #[test] + fn test_parse_header_pagedu64() { + let bytes = synth_dump64(0x116, b""); + let info = parse_minidump_header(&bytes).expect("header should parse"); + assert_eq!(info.bugcheck_code, 0x116); + assert_eq!(info.bugcheck_params[0], 0x1111111111111111); + assert_eq!(info.bugcheck_params[3], 0x4444444444444444); + } + + #[test] + fn test_parse_header_pagedump_32bit() { + let mut bytes = vec![0u8; 0x40]; + bytes[0..8].copy_from_slice(b"PAGEDUMP"); + bytes[0x28..0x2C].copy_from_slice(&0x1Au32.to_le_bytes()); + bytes[0x2C..0x30].copy_from_slice(&0xDEADBEEFu32.to_le_bytes()); + let info = parse_minidump_header(&bytes).expect("header should parse"); + assert_eq!(info.bugcheck_code, 0x1A); + assert_eq!(info.bugcheck_params[0], 0xDEADBEEF); + } + + #[test] + fn test_parse_header_rejects_garbage() { + assert!(parse_minidump_header(b"MDMP junk data here").is_none()); + assert!(parse_minidump_header(b"").is_none()); + assert!(parse_minidump_header(b"PAGEXXXX").is_none()); + } + + #[test] + fn test_extract_driver_names_ascii_and_utf16() { + let payload = b"\x00\x00nvlddmkm.sys\x00\x00ntoskrnl.exe\x00rt640x64.sys".to_vec(); + let mut utf16 = Vec::new(); + for c in "amdkmdag.sys".chars() { + utf16.extend_from_slice(&(c as u16).to_le_bytes()); + } + let mut bytes = payload; + bytes.push(0); + if bytes.len() % 2 == 1 { + bytes.push(0); // keep the UTF-16LE run 2-byte aligned + } + bytes.extend_from_slice(&utf16); + + let drivers = extract_driver_names(&bytes); + assert!(drivers.contains(&"nvlddmkm.sys".to_string())); + assert!(drivers.contains(&"rt640x64.sys".to_string())); + // Unaligned UTF-16LE runs can absorb a stray preceding byte; the + // suffix must match so driver attribution still works. + assert!(drivers.iter().any(|d| d.ends_with("amdkmdag.sys"))); + assert!(!drivers.iter().any(|d| d.ends_with(".exe"))); + + let faulting = pick_faulting_driver(&drivers); + assert_eq!(faulting.as_deref(), Some("nvlddmkm.sys")); + } + + #[test] + fn test_bugcheck_info_known_and_unknown() { + let mm = bugcheck_info(0x1A).expect("0x1A must be known"); + assert_eq!(mm.name, "MEMORY_MANAGEMENT"); + assert_eq!(mm.category, BugcheckCategory::Memory); + + let tdr = bugcheck_info(0x116).expect("0x116 must be known"); + assert_eq!(tdr.category, BugcheckCategory::VideoDriver); + + assert!(bugcheck_info(0xDEADBEEF).is_none()); + } + + #[test] + fn test_parse_bugcheck_event_english() { + let sample = r#"Event[0]: + Log Name: System + Source: BugCheck + Date: 2026-08-20T10:15:30.000Z + Event ID: 1001 + Level: Error + Description: +The computer has rebooted from a bugcheck. The bugcheck was: 0x0000001a (0x0000000000000004, 0x0000000000000000, 0x0000000000000000, 0x0000000000000000)."#; + + let events = parse_crash_events(sample); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, 1001); + assert_eq!(events[0].bugcheck_code, Some(0x1A)); + } + + #[test] + fn test_parse_bugcheck_event_german() { + let sample = r#"Ereignis[0]: + Protokollname: System + Quelle: BugCheck + Ereignis-ID: 1001 + Beschreibung: +Der Computer wurde nach der Überprüfung eines Fehlers neu gestartet. Der Fehler war: 0x000000d1 (0x0000000000000000, 0x0000000000000002)."#; + + let events = parse_crash_events(sample); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, 1001); + assert_eq!(events[0].bugcheck_code, Some(0xD1)); + } + + #[test] + fn test_parse_kernel_power_41_event() { + let sample = r#"Event[0]: + Log Name: System + Source: Microsoft-Windows-Kernel-Power + Event ID: 41 + Level: Critical + Description: +Das System wurde ohne ordnungsgemäßes Herunterfahren neu gestartet."#; + + let events = parse_crash_events(sample); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, 41); + assert!(events[0].bugcheck_code.is_none()); + } + + #[test] + fn test_parse_events_ignores_unrelated_logs() { + let sample = "Event[0]:\n Source: Service Control Manager\n Event ID: 7000\n"; + assert!(parse_crash_events(sample).is_empty()); + } + + #[tokio::test] + async fn test_scan_detects_driver_fault_from_dump() { + let dir = temp_dump_dir("drv"); + write_dump( + &dir, + "dump01.dmp", + &synth_dump64(0xD1, b"\x00nvlddmkm.sys\x00"), + ); + write_dump( + &dir, + "dump02.dmp", + &synth_dump64(0xD1, b"\x00nvlddmkm.sys\x00"), + ); + + let mock = MockCommandRunner::new(); + empty_event_response(&mock); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + + let driver_issue = issues + .iter() + .find(|i| i.id == "crash_driver_fault") + .expect("driver issue"); + assert_eq!(driver_issue.severity, Severity::Critical); // 2 crashes escalate + assert!(driver_issue.title.contains("nvlddmkm.sys")); + assert!(driver_issue.technical_details.contains("0x000000D1")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_scan_detects_memory_bugcheck_from_event() { + let dir = temp_dump_dir("mem"); + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: BugCheck + Event ID: 1001 + Description: +The computer has rebooted from a bugcheck. The bugcheck was: 0x00000050 (0xfffff80233000000)."#, + ), + ); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + + let mem = issues + .iter() + .find(|i| i.id == "crash_memory_bugcheck") + .expect("memory issue"); + assert_eq!(mem.severity, Severity::Critical); + assert!(mem.technical_details.contains("Event 1001")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_scan_detects_video_tdr() { + let dir = temp_dump_dir("tdr"); + write_dump( + &dir, + "dump01.dmp", + &synth_dump64(0x116, b"\x00dxgkrnl.sys\x00"), + ); + + let mock = MockCommandRunner::new(); + empty_event_response(&mock); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + + let tdr = issues + .iter() + .find(|i| i.id == "crash_video_tdr") + .expect("tdr issue"); + assert_eq!(tdr.severity, Severity::Critical); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_scan_detects_unexpected_shutdown() { + let dir = temp_dump_dir("kp41"); + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-Kernel-Power + Event ID: 41 + Level: Critical + Description: +The system has rebooted without cleanly shutting down first."#, + ), + ); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + + let kp = issues + .iter() + .find(|i| i.id == "crash_unexpected_shutdown") + .expect("unexpected shutdown issue"); + assert_eq!(kp.severity, Severity::Warning); + assert!(kp.technical_details.contains("Unexplained: 1")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_scan_clean_when_no_crashes() { + let dir = temp_dump_dir("clean"); + let mock = MockCommandRunner::new(); + empty_event_response(&mock); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + assert!(issues.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_scan_flags_stale_dump_accumulation() { + let dir = temp_dump_dir("stale"); + for i in 0..6 { + write_dump(&dir, &format!("dump{i}.dmp"), &synth_dump64(0xD1, b"")); + } + + let mock = MockCommandRunner::new(); + empty_event_response(&mock); + + let module = CrashAnalysisModule::with_runner_and_dump_dir( + ModuleConfig::default(), + Arc::new(mock), + &dir, + ); + let issues = module.scan(None).await.expect("scan failed"); + + let stale = issues + .iter() + .find(|i| i.id == "crash_stale_dumps") + .expect("stale issue"); + assert_eq!(stale.severity, Severity::Info); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_fix_driver_fault_opens_device_manager() { + let mock = MockCommandRunner::new(); + mock.add_response("cmd.exe", CmdOutput::ok("")); + + let module = + CrashAnalysisModule::with_runner(ModuleConfig::default(), Arc::new(mock.clone())); + let res = module.fix("crash_driver_fault", None).await; + + assert!(res.is_ok()); + let exec = mock.executed(); + assert!( + exec.iter() + .any(|c| c.contains("cmd.exe") && c.contains("devmgmt.msc")) + ); + } + + #[tokio::test] + async fn test_fix_memory_bugcheck_runs_mdsched() { + let mock = MockCommandRunner::new(); + mock.add_response("mdsched.exe", CmdOutput::ok("")); + + let module = + CrashAnalysisModule::with_runner(ModuleConfig::default(), Arc::new(mock.clone())); + let res = module.fix("crash_memory_bugcheck", None).await; + + assert!(res.is_ok()); + let exec = mock.executed(); + assert!(exec.iter().any(|c| c.contains("mdsched.exe"))); + } + + #[tokio::test] + async fn test_fix_stale_dumps_runs_powershell_remove() { + let mock = MockCommandRunner::new(); + mock.add_response("powershell.exe", CmdOutput::ok("")); + + let module = + CrashAnalysisModule::with_runner(ModuleConfig::default(), Arc::new(mock.clone())); + let res = module.fix("crash_stale_dumps", None).await; + + assert!(res.is_ok()); + let exec = mock.executed(); + assert!( + exec.iter() + .any(|c| c.contains("Remove-Item") && c.contains("Minidump")) + ); + } + + #[tokio::test] + async fn test_fix_advisory_findings_record_audit_note() { + let mock = MockCommandRunner::new(); + let module = CrashAnalysisModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + + let res = module.fix("crash_unexpected_shutdown", None).await; + assert!(res.is_ok()); + assert!(res.unwrap().contains("audit trail")); + + let res = module.fix("crash_bugcheck_history", None).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_fix_unknown_id_returns_error() { + let mock = MockCommandRunner::new(); + let module = CrashAnalysisModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let res = module.fix("nonexistent_id", None).await; + + assert!(res.is_err()); + assert!(res.unwrap_err().contains("Unknown crash analysis issue id")); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index a8606c3..ff7dd59 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,3 +1,4 @@ +pub mod crash_analysis; pub mod event_log; pub mod network; pub mod page_file; @@ -6,10 +7,12 @@ pub mod scheduled_tasks; pub mod storage; pub mod system_cleaner; pub mod system_integrity; +pub mod whea_logger; pub mod windows_updates; use crate::config::AppConfig; use crate::engine::issue::Issue; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::mpsc::Sender; @@ -44,7 +47,7 @@ impl From<&AppConfig> for ModuleConfig { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ModuleStatus { Idle, Scanning, @@ -123,7 +126,15 @@ pub fn get_all_modules_with_runner( Arc::new(scheduled_tasks::ScheduledTasksModule::with_runner( runner.clone(), )), - Arc::new(page_file::PageFileModule::with_runner(runner)), + Arc::new(page_file::PageFileModule::with_runner(runner.clone())), + Arc::new(whea_logger::WheaLoggerModule::with_runner( + cfg.clone(), + runner.clone(), + )), + Arc::new(crash_analysis::CrashAnalysisModule::with_runner( + cfg.clone(), + runner, + )), ] } diff --git a/src/modules/network.rs b/src/modules/network.rs index b9b4974..be0df6c 100644 --- a/src/modules/network.rs +++ b/src/modules/network.rs @@ -8,6 +8,20 @@ use tokio::time::sleep; use winreg::RegKey; use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE}; +/// Names the resolver check asks for, in order. +/// +/// Two of them, from two different operators, because one name failing is not +/// evidence that name resolution is broken — a single domain can be blocked, +/// blackholed by a filtering resolver, or simply have a bad day. The check only +/// reports a failure when *no* probe resolves. +const DNS_PROBE_NAMES: &[&str] = &["dns.google", "www.microsoft.com"]; + +/// What one resolver probe did. +struct DnsProbe { + resolved: bool, + detail: String, +} + pub struct NetworkModule { runner: Arc, } @@ -27,6 +41,80 @@ impl NetworkModule { Self { runner } } + /// Whether an `nslookup` run actually returned an answer record. + /// + /// Substring checks against the raw output do not work here: nslookup + /// echoes the resolver it used as a `Server:` / `Address:` header *before* + /// it reports anything, so both the server's name and the word `Address` + /// are present even when the lookup failed outright. An answer section is + /// what distinguishes the two, and it is introduced by a `Name:` line — + /// on English and German Windows alike. Failure output ("can't find", + /// "Non-existent domain", "DNS request timed out") never carries one. + fn nslookup_resolved(stdout: &str) -> bool { + stdout.lines().any(|line| { + line.trim_start() + .strip_prefix("Name:") + .is_some_and(|name| !name.trim().is_empty()) + }) + } + + /// Ask the machine's own resolver for one name. + /// + /// Deliberately *without* a server argument. Passing one (`nslookup name + /// 8.8.8.8`) bypasses the configured resolver and queries that server + /// directly over port 53, which corporate networks, filtering routers, + /// VPN split-DNS setups and DNS-over-HTTPS-only configurations all block + /// as a matter of policy. On such a machine the probe failed while name + /// resolution was perfectly healthy, and the resulting critical finding + /// came back on every scan — `ipconfig /flushdns` cannot unblock somebody + /// else's firewall. + async fn probe_name(&self, name: &str) -> DnsProbe { + match self + .runner + .run("nslookup.exe", &[name], Duration::from_secs(10)) + .await + { + Ok(out) if Self::nslookup_resolved(&out.stdout) => DnsProbe { + resolved: true, + detail: format!("nslookup {} resolved", name), + }, + Ok(out) => { + let reason = out + .stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with("***") || line.contains("timed out")) + .map(str::to_string) + .unwrap_or_else(|| "no answer record returned".to_string()); + DnsProbe { + resolved: false, + detail: format!("nslookup {}: {}", name, reason), + } + } + Err(err) => DnsProbe { + resolved: false, + detail: format!("nslookup {} could not be run: {}", name, err), + }, + } + } + + /// Whether the system resolver answers at all, with the per-probe log. + /// + /// The log is returned either way: it is what the finding's technical + /// details show, and what the repair uses to say whether it changed + /// anything. + async fn resolver_works(&self) -> (bool, Vec) { + let mut log = Vec::new(); + for name in DNS_PROBE_NAMES { + let probe = self.probe_name(name).await; + log.push(probe.detail); + if probe.resolved { + return (true, log); + } + } + (false, log) + } + async fn send_progress( progress_tx: &Option>, percent: u8, @@ -75,25 +163,12 @@ impl DiagnosticModule for NetworkModule { &progress_tx, 20, "Testing DNS name resolution...", - Some("Looking up Google & Cloudflare DNS..."), + Some("Asking the machine's own resolver for two independent names..."), ) .await; sleep(Duration::from_millis(150)).await; - let dns_lookup = self - .runner - .run( - "nslookup.exe", - &["dns.google", "8.8.8.8"], - Duration::from_secs(6), - ) - .await; - let mut dns_healthy = false; - if let Ok(out) = dns_lookup - && (out.stdout.contains("8.8.8.8") || out.stdout.contains("Address")) - { - dns_healthy = true; - } + let (dns_healthy, probe_log) = self.resolver_works().await; if !dns_healthy { let ping_test = self @@ -109,6 +184,8 @@ impl DiagnosticModule for NetworkModule { Err(_) => false, }; + let evidence = probe_log.join("\n"); + if ip_reachable { issues.push(Issue::new( "net_dns_failure", @@ -118,11 +195,12 @@ impl DiagnosticModule for NetworkModule { Severity::Critical, RiskScore::Low, "Websites cannot be resolved by domain name even though IP connectivity to the internet works. The usual cause is a stale DNS cache or broken resolver settings.", - "nslookup failed, ping 1.1.1.1 succeeded", + format!("{}\nping 1.1.1.1 succeeded", evidence), "Flush the DNS cache (ipconfig /flushdns) and re-register the DNS resolver", vec![ "Run ipconfig /flushdns".to_string(), "Run ipconfig /registerdns".to_string(), + "Confirm that name resolution works again".to_string(), ], )); } else { @@ -134,7 +212,7 @@ impl DiagnosticModule for NetworkModule { Severity::Warning, RiskScore::Low, "The system can reach neither external IP addresses nor DNS servers. Check the router, the Wi-Fi/LAN cable or any VPN connection.", - "No response to ping / nslookup", + format!("{}\nping 1.1.1.1 got no reply", evidence), "Reset the network adapter and the Winsock / IP stack", vec![ "netsh winsock reset".to_string(), @@ -262,7 +340,21 @@ impl DiagnosticModule for NetworkModule { .runner .run("ipconfig.exe", &["/registerdns"], Duration::from_secs(8)) .await; - Ok("DNS cache flushed and DNS resolver re-registered successfully.".to_string()) + + // Neither command reports whether resolution actually recovered, + // and both exit zero on a machine whose resolver is still dead. + // Reporting success there marked the issue fixed, and the next + // scan raised the identical critical finding - the loop the user + // sees. Asking the resolver again is the only honest answer. + let (resolves, probe_log) = self.resolver_works().await; + if resolves { + Ok("DNS cache flushed and the resolver re-registered - name resolution is working again.".to_string()) + } else { + Err(format!( + "The DNS cache was flushed and the resolver re-registered, but names still do not resolve: {}. The fault is outside what WinMedic can reset - check the DNS servers configured on the adapter, the router, or an active VPN.", + probe_log.join("; ") + )) + } } "net_offline_warning" | "net_winsock_corrupt" => { let _ = self @@ -314,6 +406,99 @@ mod tests { use super::*; use crate::utils::cmd::{CmdOutput, MockCommandRunner}; + /// What `nslookup dns.google` prints when the resolver answers. + const RESOLVED_OUTPUT: &str = "Server: fritz.box\r\n\ + Address: 192.168.178.1\r\n\ + \r\n\ + Nicht autorisierende Antwort:\r\n\ + Name: dns.google\r\n\ + Addresses: 8.8.8.8\r\n\ + \t 8.8.4.4\r\n"; + + /// What it prints when the query reached a server that had no answer. + const REFUSED_OUTPUT: &str = "Server: fritz.box\r\n\ + Address: 192.168.178.1\r\n\ + \r\n\ + *** fritz.box can't find dns.google: Non-existent domain\r\n"; + + /// What it prints when the queried server never answered at all - the shape + /// a pinned public resolver produces on a network that blocks port 53. + const TIMED_OUT_OUTPUT: &str = "DNS request timed out.\r\n\ + \ttimeout was 2 seconds.\r\n\ + Server: UnKnown\r\n\ + Address: 8.8.8.8\r\n\ + \r\n\ + *** Request to UnKnown timed-out\r\n"; + + #[test] + fn only_an_answer_record_counts_as_resolved() { + assert!(NetworkModule::nslookup_resolved(RESOLVED_OUTPUT)); + + // Both of these carry the words "Address" and the queried server, which + // is why a substring check reported a working resolver here. + assert!(!NetworkModule::nslookup_resolved(REFUSED_OUTPUT)); + assert!(!NetworkModule::nslookup_resolved(TIMED_OUT_OUTPUT)); + assert!(!NetworkModule::nslookup_resolved("")); + } + + #[tokio::test] + async fn the_resolver_check_never_pins_a_public_dns_server() { + // A machine whose own resolver works, on a network that blocks outbound + // queries to 8.8.8.8. Pinning that server reported a critical DNS + // failure here that no repair could ever clear. + let mock = MockCommandRunner::new(); + mock.add_response("nslookup.exe", CmdOutput::ok(RESOLVED_OUTPUT)); + mock.add_response( + "netsh.exe", + CmdOutput::ok("Winsock Catalog Provider: MSAFD Tcpip"), + ); + + let module = NetworkModule::with_runner(Arc::new(mock.clone())); + let issues = module.scan(None).await.unwrap(); + + assert!( + !issues.iter().any(|i| i.id == "net_dns_failure"), + "a working resolver must not be reported" + ); + let lookup = mock + .executed() + .into_iter() + .find(|c| c.contains("nslookup")) + .expect("the scan must ask the resolver"); + assert_eq!( + lookup, "nslookup.exe dns.google", + "no server argument - the query has to go through the configured resolver" + ); + } + + #[tokio::test] + async fn a_repair_that_did_not_restore_resolution_reports_a_failure() { + let mock = MockCommandRunner::new(); + mock.add_response("nslookup.exe", CmdOutput::ok(REFUSED_OUTPUT)); + mock.add_response("ipconfig.exe", CmdOutput::ok("")); + + let module = NetworkModule::with_runner(Arc::new(mock)); + let err = module.fix("net_dns_failure", None).await.unwrap_err(); + + assert!(err.contains("still do not resolve")); + assert!( + err.contains("dns.google"), + "the message has to say what was tried" + ); + } + + #[tokio::test] + async fn a_repair_that_restored_resolution_reports_success() { + let mock = MockCommandRunner::new(); + mock.add_response("nslookup.exe", CmdOutput::ok(RESOLVED_OUTPUT)); + mock.add_response("ipconfig.exe", CmdOutput::ok("")); + + let module = NetworkModule::with_runner(Arc::new(mock)); + let msg = module.fix("net_dns_failure", None).await.unwrap(); + + assert!(msg.contains("working again")); + } + #[tokio::test] async fn test_network_detects_dns_failure() { let mock = MockCommandRunner::new(); diff --git a/src/modules/page_file.rs b/src/modules/page_file.rs index f736588..5f01365 100644 --- a/src/modules/page_file.rs +++ b/src/modules/page_file.rs @@ -463,7 +463,8 @@ impl DiagnosticModule for PageFileModule { "Set Win32_ComputerSystem.AutomaticManagedPagefile to $true".to_string(), "Restart Windows so the page file is created".to_string(), ], - ); + ) + .with_requires_reboot(true); issue.is_selected = false; issues.push(issue); } @@ -617,7 +618,8 @@ impl DiagnosticModule for PageFileModule { ), "Restart Windows so the new size applies".to_string(), ], - ); + ) + .with_requires_reboot(true); issue.is_selected = false; issues.push(issue); } diff --git a/src/modules/scheduled_tasks.rs b/src/modules/scheduled_tasks.rs index 0ff221d..6ede9d9 100644 --- a/src/modules/scheduled_tasks.rs +++ b/src/modules/scheduled_tasks.rs @@ -54,6 +54,15 @@ const BENIGN_TASK_RESULTS: &[i64] = &[ /// Stands in for a PowerShell failure that carried no stderr of its own. const NO_DETAIL: &str = "PowerShell reported no detail"; +/// Prefix the disable script uses to report the task's state back to WinMedic. +/// +/// `Disable-ScheduledTask` succeeding is not the same as the task being off. +/// A task the machine protects can swallow the change and still exit zero, and +/// reporting that as a repaired issue is what left the user fixing the same +/// finding over and over. Reading the state back turns that into an honest +/// failure the user can act on. +const STATE_MARKER: &str = "WINMEDIC_TASK_STATE="; + /// A task as the inventory script reported it. #[derive(Debug, Clone, PartialEq, Eq)] struct ScheduledTask { @@ -80,6 +89,19 @@ impl ScheduledTask { None => false, } } + + /// Whether Task Scheduler currently refuses to run this task at all. + /// + /// This is exactly the state [`ScheduledTasksModule::fix`] leaves a task + /// in, and it is what makes a repair stick: a disabled task fires on no + /// trigger, so neither a deleted target nor a stale `LastTaskResult` can + /// cause anything further. Without this check the scan re-raised every + /// task it had just switched off — Windows never resets `LastTaskResult`, + /// and disabling a task obviously does not restore its missing program — + /// so the same findings came back on every run, repair after repair. + fn is_disabled(&self) -> bool { + self.state.trim().eq_ignore_ascii_case("Disabled") + } } /// What [`ScheduledTasksModule::fix`] needs to address a finding, kept from the @@ -290,28 +312,123 @@ impl ScheduledTasksModule { } } + /// The state the disable script read back, if it got that far and the + /// service answered. + fn parse_reported_state(stdout: &str) -> Option { + stdout + .lines() + .find_map(|line| line.trim().strip_prefix(STATE_MARKER)) + .map(str::trim) + .filter(|state| !state.is_empty()) + .map(str::to_string) + } + fn lookup(&self, issue_id: &str) -> Option { self.known_tasks.lock().ok()?.get(issue_id).cloned() } + /// Query the registered scheduled tasks from the system to populate known tasks + /// when the module has no cached entries (e.g. after an app restart or engine rebuild). + async fn refresh_known_tasks(&self) -> Result<(), String> { + let script = r#"Get-ScheduledTask | ForEach-Object { "$($_.TaskPath)|$($_.TaskName)" }"#; + let out = self + .runner + .run_powershell(script, Duration::from_secs(30)) + .await?; + + if !out.success && out.stdout.trim().is_empty() { + return Err(out.stderr); + } + + if let Ok(mut known) = self.known_tasks.lock() { + for line in out.stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some((path, name)) = trimmed.split_once('|') { + let path = path.trim(); + let name = name.trim(); + if name.is_empty() { + continue; + } + let full = format!("{}{}", path, name); + let task_ref = TaskRef { + path: path.to_string(), + name: name.to_string(), + }; + let orphaned_id = Self::issue_id("sched_orphaned", &full); + let failing_id = Self::issue_id("sched_failing", &full); + known.insert(orphaned_id, task_ref.clone()); + known.insert(failing_id, task_ref); + } + } + } + Ok(()) + } + + /// Look up a task by issue id, dynamically falling back to querying the system + /// if the in-memory cache is empty. + async fn lookup_or_refresh(&self, issue_id: &str) -> Option { + if let Some(task) = self.lookup(issue_id) { + return Some(task); + } + + let _ = self.refresh_known_tasks().await; + self.lookup(issue_id) + } + /// Disable a task by its exact folder and name. /// /// Both values come from the system, not from WinMedic, so both go through /// [`ps_single_quoted`] — a task can be named by anyone who can create one, /// and this command frequently runs elevated. + /// + /// If standard `Disable-ScheduledTask` fails with permission denied (common on + /// protected Windows tasks such as `UpdateOrchestrator\USO_UxBroker`), it takes + /// ownership of the task file in System32\Tasks and grants Administrators permissions + /// before retrying. async fn disable_task(&self, task: &TaskRef) -> Result { let script = format!( - "Disable-ScheduledTask -TaskPath {} -TaskName {} -ErrorAction Stop | Out-Null", + concat!( + "try {{ ", + "Disable-ScheduledTask -TaskPath {0} -TaskName {1} -ErrorAction Stop | Out-Null ", + "}} catch {{ ", + "$taskPath = {0}; $taskName = {1}; ", + "$cleanRel = ($taskPath.Trim('\\') + '\\' + $taskName).Trim('\\'); ", + "$taskFile = Join-Path $env:SystemRoot \"System32\\Tasks\\$cleanRel\"; ", + "if (Test-Path $taskFile) {{ ", + "takeown.exe /f $taskFile /a | Out-Null; ", + "icacls.exe $taskFile /grant \"*S-1-5-32-544:F\" | Out-Null; ", + "Disable-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction Stop | Out-Null ", + "}} else {{ throw $_ }} ", + "}} ", + // Read the state back from the service rather than trusting the + // cmdlet's exit code. Runs only when the disable did not throw. + "$state = (Get-ScheduledTask -TaskPath {0} -TaskName {1} -ErrorAction SilentlyContinue).State; ", + "Write-Output \"WINMEDIC_TASK_STATE=$state\"" + ), ps_single_quoted(&task.path), ps_single_quoted(&task.name), ); let out = self .runner - .run_powershell(&script, Duration::from_secs(20)) + .run_powershell(&script, Duration::from_secs(25)) .await?; if out.success { + // A state the service would not report at all (the task was removed + // meanwhile, or is unreadable from here) is not evidence of failure, + // so only a state that positively contradicts the repair fails it. + if let Some(state) = Self::parse_reported_state(&out.stdout) + && !state.eq_ignore_ascii_case("Disabled") + { + return Err(format!( + "'{}{}' still reports state '{}' after Disable-ScheduledTask. Windows accepted the command without applying it, which happens on tasks owned by TrustedInstaller — disable it from Task Scheduler, or leave it alone.", + task.path, task.name, state + )); + } return Ok(format!( "Scheduled task '{}{}' disabled. Re-enable it at any time with 'Enable-ScheduledTask'.", task.path, task.name @@ -393,8 +510,20 @@ impl DiagnosticModule for ScheduledTasksModule { sleep(Duration::from_millis(150)).await; // 1. Tasks whose program no longer exists. + // + // Tasks that are already switched off are skipped in both checks below. + // Disabling is the only thing a repair here does, so re-reporting a + // disabled task means reporting a finding the user has already dealt + // with — and one no repair could ever clear, since neither the missing + // program nor the recorded `LastTaskResult` changes by switching a task + // off. That is what made these findings return on every single scan. + let mut already_disabled = 0; let mut orphaned = 0; for task in &tasks { + if task.is_disabled() { + already_disabled += 1; + continue; + } let Some(missing) = Self::missing_target(&task.execute) else { continue; }; @@ -448,6 +577,9 @@ impl DiagnosticModule for ScheduledTasksModule { // something the user wants repaired rather than switched off. let mut failing = 0; for task in &tasks { + if task.is_disabled() { + continue; + } if !task.last_run_failed() { continue; } @@ -509,10 +641,11 @@ impl DiagnosticModule for ScheduledTasksModule { 100, "Scheduled task diagnostics complete", Some(&format!( - "{} tasks checked: {} with a missing target, {} with a failed last run.", + "{} tasks checked: {} with a missing target, {} with a failed last run, {} already disabled (skipped).", tasks.len(), orphaned, - failing + failing, + already_disabled )), ) .await; @@ -529,7 +662,7 @@ impl DiagnosticModule for ScheduledTasksModule { return Err(format!("Unknown issue id: {}", issue_id)); } - let Some(task) = self.lookup(issue_id) else { + let Some(task) = self.lookup_or_refresh(issue_id).await else { return Err(format!( "No scheduled task is on record for '{}'. Run the scan again before repairing.", issue_id @@ -812,6 +945,102 @@ mod tests { assert!(disable.contains(r"'Evil''; Remove-Item -Recurse C:\Windows; '''")); } + #[tokio::test] + async fn a_task_that_was_already_disabled_is_not_reported_again() { + // Exactly the state `fix` leaves a task in. Re-reporting it here is what + // made repaired findings come back on every scan. + let module = module_with(vec![ + format!(r"\Vendor\|Ghost Updater|Disabled|0|0|{}", MISSING_EXE), + format!(r"\Vendor\|Flaky Sync|Disabled|2147942402|3|{}", live_exe()), + ]); + + assert!( + module.scan(None).await.unwrap().is_empty(), + "a disabled task fires on no trigger - neither check has anything left to report" + ); + } + + #[tokio::test] + async fn the_same_task_stops_being_reported_once_it_is_disabled() { + let before = module_with(vec![format!( + r"\Vendor\|Ghost Updater|Ready|0|0|{}", + MISSING_EXE + )]); + assert_eq!(before.scan(None).await.unwrap().len(), 1); + + // The identical inventory line as the system reports it after a repair. + let after = module_with(vec![format!( + r"\Vendor\|Ghost Updater|Disabled|0|0|{}", + MISSING_EXE + )]); + assert!(after.scan(None).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn a_disable_windows_swallows_is_reported_as_a_failure() { + let mock = MockCommandRunner::new(); + // Exit code zero, but the task is still armed - what a TrustedInstaller + // owned task such as the UpdateOrchestrator brokers does. + mock.add_response( + "Disable-ScheduledTask", + CmdOutput::ok("WINMEDIC_TASK_STATE=Ready"), + ); + mock.add_response( + "Get-ScheduledTask", + inventory(vec![format!( + r"\Vendor\|Ghost Updater|Ready|0|0|{}", + MISSING_EXE + )]), + ); + let module = ScheduledTasksModule::with_runner(Arc::new(mock)); + + let issues = module.scan(None).await.unwrap(); + let err = module.fix(&issues[0].id, None).await.unwrap_err(); + + assert!(err.contains("still reports state 'Ready'")); + assert!(err.contains("Ghost Updater")); + } + + #[tokio::test] + async fn a_disable_the_service_confirms_succeeds() { + let mock = MockCommandRunner::new(); + mock.add_response( + "Disable-ScheduledTask", + CmdOutput::ok("WINMEDIC_TASK_STATE=Disabled"), + ); + mock.add_response( + "Get-ScheduledTask", + inventory(vec![format!( + r"\Vendor\|Ghost Updater|Ready|0|0|{}", + MISSING_EXE + )]), + ); + let module = ScheduledTasksModule::with_runner(Arc::new(mock)); + + let issues = module.scan(None).await.unwrap(); + assert!( + module + .fix(&issues[0].id, None) + .await + .unwrap() + .contains("disabled") + ); + } + + #[test] + fn a_state_the_service_would_not_report_is_not_read_as_a_failure() { + // `$state` on a task that vanished renders as the empty string. + assert_eq!( + ScheduledTasksModule::parse_reported_state("WINMEDIC_TASK_STATE="), + None + ); + assert_eq!(ScheduledTasksModule::parse_reported_state(""), None); + assert_eq!( + ScheduledTasksModule::parse_reported_state("noise\r\nWINMEDIC_TASK_STATE=Disabled\r\n"), + Some("Disabled".to_string()) + ); + } + #[tokio::test] async fn a_fix_without_a_preceding_scan_refuses_instead_of_guessing() { let module = module_with(Vec::new()); @@ -838,4 +1067,28 @@ mod tests { let err = module.scan(None).await.unwrap_err(); assert!(err.contains("Access to the task scheduler service was denied.")); } + + #[tokio::test] + async fn fix_without_prior_scan_resolves_task_from_system() { + let mock = MockCommandRunner::new(); + mock.add_response("Disable-ScheduledTask", CmdOutput::ok("")); + mock.add_response( + "Get-ScheduledTask", + CmdOutput::ok(r"\Vendor\|Orphaned Task"), + ); + let module = ScheduledTasksModule::with_runner(Arc::new(mock.clone())); + + let issue_id = ScheduledTasksModule::issue_id("sched_orphaned", r"\Vendor\Orphaned Task"); + // No scan() is called here; fix() must dynamically query the system. + let result = module.fix(&issue_id, None).await.unwrap(); + + assert!(result.contains("Orphaned Task")); + let disable = mock + .executed() + .into_iter() + .find(|c| c.contains("Disable-ScheduledTask")) + .expect("the fix must issue the disable command"); + assert!(disable.contains(r"-TaskPath '\Vendor\'")); + assert!(disable.contains("-TaskName 'Orphaned Task'")); + } } diff --git a/src/modules/system_cleaner.rs b/src/modules/system_cleaner.rs index 27fb76e..c9d7832 100644 --- a/src/modules/system_cleaner.rs +++ b/src/modules/system_cleaner.rs @@ -101,10 +101,19 @@ pub fn cleanup_result(label: &str, stats: CleanStats) -> Result /// Clean files and subdirectories within a path, safely skipping locked files. pub fn clean_path_contents(path: &Path) -> CleanStats { + clean_filtered(path, |_| false) +} + +/// The sweep behind [`clean_path_contents`], leaving alone whatever `skip` +/// claims. +fn clean_filtered(path: &Path, skip: fn(&Path) -> bool) -> CleanStats { let mut stats = CleanStats::default(); if let Ok(entries) = std::fs::read_dir(path) { for entry in entries.flatten() { let p = entry.path(); + if skip(&p) { + continue; + } if let Ok(meta) = p.symlink_metadata() { if meta.is_file() || meta.is_symlink() { let len = meta.len(); @@ -116,7 +125,7 @@ pub fn clean_path_contents(path: &Path) -> CleanStats { stats.locked_bytes += len; } } else if meta.is_dir() { - let sub = clean_path_contents(&p); + let sub = clean_filtered(&p, skip); stats.freed_bytes += sub.freed_bytes; stats.deleted_files += sub.deleted_files; stats.skipped_locked += sub.skipped_locked; @@ -272,6 +281,88 @@ pub fn get_user_profile() -> PathBuf { .unwrap_or_else(|_| PathBuf::from(r"C:\Users\Default")) } +/// The smallest amount of reclaimable data worth raising a cleanup finding for. +/// +/// Every directory this module sweeps is one Windows refills by itself. A +/// running service writes its next log line, Explorer rewrites a shell stub, a +/// browser caches the next favicon — all within seconds of a repair finishing. +/// Reporting on any non-zero byte therefore produced findings that came back on +/// the very next scan no matter how well the repair had worked, which read as a +/// broken repair rather than as a machine doing its job. +/// +/// A floor is what separates "there is something here worth reclaiming" from +/// "the system has started refilling an empty directory". Below it there is +/// nothing for the user to decide. +pub const MIN_REPORTABLE_CLEANUP_BYTES: u64 = 10 * 1024 * 1024; + +/// The same floor for browser caches, which refill faster than anything else +/// here: a few minutes of ordinary browsing puts tens of megabytes back, and a +/// running browser holds part of its cache open so a sweep can never empty it +/// completely in the first place. +pub const MIN_REPORTABLE_BROWSER_CACHE_BYTES: u64 = 50 * 1024 * 1024; + +/// Whether a finding is worth raising for the data actually found. +/// +/// Byte count only: a directory holding a thousand empty placeholder files has +/// nothing to reclaim, and every one of them would survive the sweep anyway. +pub fn worth_reporting(stats: DirStats, floor_bytes: u64) -> bool { + stats.bytes >= floor_bytes +} + +/// Files Windows writes back the moment they are removed. +/// +/// `$Recycle.Bin\\desktop.ini` is the one that matters: it is a 129-byte +/// shell stub carrying the folder's CLSID and localised name, Explorer +/// recreates it as soon as the Recycle Bin is touched, and `Clear-RecycleBin` +/// never removes it. Counting it made an empty Recycle Bin report "129 B, 1 +/// files" on every scan, forever — a finding whose repair could not possibly +/// clear it. +fn is_recreated_shell_stub(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("desktop.ini")) +} + +/// Measure a tree, leaving out whatever `skip` claims. +fn dir_stats_filtered(path: &Path, skip: fn(&Path) -> bool) -> DirStats { + let mut stats = DirStats::default(); + let Ok(entries) = std::fs::read_dir(path) else { + return stats; + }; + for entry in entries.flatten() { + let p = entry.path(); + if skip(&p) { + continue; + } + let Ok(meta) = p.symlink_metadata() else { + continue; + }; + if meta.is_file() || meta.is_symlink() { + stats.bytes += meta.len(); + stats.files += 1; + } else if meta.is_dir() { + let sub = dir_stats_filtered(&p, skip); + stats.bytes += sub.bytes; + stats.files += sub.files; + } + } + stats +} + +/// What the Recycle Bin is really holding, ignoring the stubs Windows owns. +pub fn scan_recycle_bin_contents(path: &Path) -> DirStats { + dir_stats_filtered(path, is_recreated_shell_stub) +} + +/// Empty the Recycle Bin directories, leaving the stubs Windows owns in place. +/// +/// Deleting `desktop.ini` here frees 129 bytes, costs the Recycle Bin its +/// localised name until Explorer notices, and changes nothing about the next +/// scan — since the scan no longer counts it either. +pub fn clean_recycle_bin_contents(path: &Path) -> CleanStats { + clean_filtered(path, is_recreated_shell_stub) +} + pub fn discover_delivery_optimization_dirs(sys_root: &Path) -> Vec { vec![ sys_root @@ -289,54 +380,91 @@ pub fn discover_delivery_optimization_dirs(sys_root: &Path) -> Vec { ] } -pub fn discover_browser_cache_dirs(local_app_data: &Path, app_data: &Path) -> Vec { - let mut dirs = Vec::new(); +/// The cache directories a Chromium profile keeps. +const CHROMIUM_CACHE_DIRS: &[&str] = &["Cache", "Code Cache", "GPUCache"]; - // Chrome - let chrome_user_data = local_app_data - .join("Google") - .join("Chrome") - .join("User Data"); - if chrome_user_data.exists() - && let Ok(entries) = std::fs::read_dir(&chrome_user_data) - { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - if name == "Default" || name.starts_with("Profile ") { - dirs.push(p.join("Cache")); - dirs.push(p.join("Code Cache")); - dirs.push(p.join("GPUCache")); - } - } +/// Append every profile cache under a Chromium `User Data` root. +/// +/// Chrome, Edge and Brave share this layout exactly: one directory per profile, +/// named `Default` or `Profile N`, each holding the same three caches. Only +/// directories matching those names are collected, so a stray folder somebody +/// dropped into `User Data` is never swept. +fn push_chromium_profile_caches(user_data: &Path, dirs: &mut Vec) { + let Ok(entries) = std::fs::read_dir(user_data) else { + return; + }; + for entry in entries.flatten() { + let profile = entry.path(); + if !profile.is_dir() { + continue; + } + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if name != "Default" && !name.starts_with("Profile ") { + continue; + } + for cache in CHROMIUM_CACHE_DIRS { + dirs.push(profile.join(cache)); } } +} - // Edge - let edge_user_data = local_app_data - .join("Microsoft") - .join("Edge") - .join("User Data"); - if edge_user_data.exists() - && let Ok(entries) = std::fs::read_dir(&edge_user_data) - { +/// Append the caches of every installed Opera flavour. +/// +/// Opera is Chromium underneath but does not use a `User Data` root. Each +/// installed flavour — Opera Stable, Opera GX Stable, Opera One, Opera Crypto +/// Stable — gets its own directory under `Opera Software`, and that directory +/// *is* the profile. The flavours are enumerated rather than named, so one +/// Opera ships next needs no code change here. +/// +/// Both roots are checked because the caches moved: current builds keep them +/// under LocalAppData, older ones under Roaming, and a machine that has been +/// upgraded still has the old directory sitting there. +fn push_opera_caches(local_app_data: &Path, app_data: &Path, dirs: &mut Vec) { + for root in [ + local_app_data.join("Opera Software"), + app_data.join("Opera Software"), + ] { + let Ok(entries) = std::fs::read_dir(&root) else { + continue; + }; for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - if name == "Default" || name.starts_with("Profile ") { - dirs.push(p.join("Cache")); - dirs.push(p.join("Code Cache")); - dirs.push(p.join("GPUCache")); - } + let flavour = entry.path(); + if !flavour.is_dir() { + continue; + } + for cache in CHROMIUM_CACHE_DIRS { + dirs.push(flavour.join(cache)); } } } +} + +pub fn discover_browser_cache_dirs(local_app_data: &Path, app_data: &Path) -> Vec { + let mut dirs = Vec::new(); + + // Chrome, Edge and Brave, all three on the same `User Data` layout. + for user_data in [ + local_app_data + .join("Google") + .join("Chrome") + .join("User Data"), + local_app_data + .join("Microsoft") + .join("Edge") + .join("User Data"), + local_app_data + .join("BraveSoftware") + .join("Brave-Browser") + .join("User Data"), + ] { + push_chromium_profile_caches(&user_data, &mut dirs); + } + + push_opera_caches(local_app_data, app_data, &mut dirs); - // Firefox + // Firefox keeps one `cache2` per profile, and the profile root moved + // between releases, so both locations are enumerated. let ff_roots = [ local_app_data .join("Mozilla") @@ -345,14 +473,13 @@ pub fn discover_browser_cache_dirs(local_app_data: &Path, app_data: &Path) -> Ve app_data.join("Mozilla").join("Firefox").join("Profiles"), ]; for ff_root in &ff_roots { - if ff_root.exists() - && let Ok(entries) = std::fs::read_dir(ff_root) - { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - dirs.push(p.join("cache2")); - } + let Ok(entries) = std::fs::read_dir(ff_root) else { + continue; + }; + for entry in entries.flatten() { + let profile = entry.path(); + if profile.is_dir() { + dirs.push(profile.join("cache2")); } } } @@ -525,10 +652,15 @@ impl SystemCleanerModule { /// a Tokio worker for minutes, and — having no await point — would make the /// scan task un-abortable, so `[Esc]` could not cancel it. async fn scan_dirs(dirs: Vec) -> DirStats { + Self::scan_dirs_with(dirs, scan_path_recursive).await + } + + /// Like [`Self::scan_dirs`], but measuring each directory with `measure`. + async fn scan_dirs_with(dirs: Vec, measure: fn(&Path) -> DirStats) -> DirStats { tokio::task::spawn_blocking(move || { let mut total = DirStats::default(); for dir in &dirs { - let stats = scan_path_recursive(dir); + let stats = measure(dir); total.bytes += stats.bytes; total.files += stats.files; } @@ -762,7 +894,7 @@ impl DiagnosticModule for SystemCleanerModule { let wudo_dirs = discover_delivery_optimization_dirs(&sys_root); let wudo_stats = Self::scan_dirs(wudo_dirs).await; - if wudo_stats.bytes > 0 || wudo_stats.files > 0 { + if worth_reporting(wudo_stats, MIN_REPORTABLE_CLEANUP_BYTES) { issues.push(Issue::new( "sys_clean_delivery_optimization", self.id(), @@ -801,7 +933,7 @@ impl DiagnosticModule for SystemCleanerModule { let pkg_cache_dir = prog_data.join("Package Cache"); let pkg_stats = Self::scan_dirs(vec![pkg_cache_dir]).await; - if pkg_stats.bytes > 0 || pkg_stats.files > 0 { + if worth_reporting(pkg_stats, MIN_REPORTABLE_CLEANUP_BYTES) { let mut pkg_issue = Issue::new( "sys_clean_package_cache", self.id(), @@ -837,8 +969,8 @@ impl DiagnosticModule for SystemCleanerModule { Self::send_progress( &progress_tx, 46, - "Checking browser caches (Chrome, Edge, Firefox)...", - Some("Scanning the Chrome, Edge and Firefox profiles..."), + "Checking browser caches (Chrome, Edge, Brave, Opera, Firefox)...", + Some("Scanning the installed Chromium and Firefox profiles..."), ) .await; @@ -847,26 +979,28 @@ impl DiagnosticModule for SystemCleanerModule { let browser_dirs = discover_browser_cache_dirs(&local_app_data, &app_data); let browser_stats = Self::scan_dirs(browser_dirs).await; - if browser_stats.bytes > 0 || browser_stats.files > 0 { + if worth_reporting(browser_stats, MIN_REPORTABLE_BROWSER_CACHE_BYTES) { issues.push(Issue::new( "sys_clean_browser_cache", self.id(), format!( - "Browser caches (Chrome, Edge, Firefox) ({}, {} files)", + "Browser caches (Chrome, Edge, Brave, Opera, Firefox) ({}, {} files)", format_bytes(browser_stats.bytes), browser_stats.files ), "System & Cache Cleaner", Severity::Info, RiskScore::Low, - "Browsers keep HTTP and script caches for faster load times. These caches can grow to several gigabytes.", + "Browsers keep HTTP and script caches for faster load times. These caches can grow to several gigabytes. Chrome, Edge, Brave, every installed Opera flavour and Firefox are covered, across all of their profiles.", format!( "Total browser cache size: {} across {} files in the detected profiles", format_bytes(browser_stats.bytes), browser_stats.files ), "Clean browser caches (files held by active browser sessions are safely skipped)", - vec!["Empty the Chrome / Edge / Firefox cache directories".to_string()], + vec![ + "Empty the Chrome / Edge / Brave / Opera / Firefox cache directories".to_string(), + ], )); } @@ -882,7 +1016,7 @@ impl DiagnosticModule for SystemCleanerModule { let setup_log_dirs = discover_setup_log_dirs(&sys_root); let setup_log_stats = Self::scan_log_dirs(setup_log_dirs).await; - if setup_log_stats.bytes > 0 || setup_log_stats.files > 0 { + if worth_reporting(setup_log_stats, MIN_REPORTABLE_CLEANUP_BYTES) { issues.push(Issue::new( "sys_clean_setup_logs", self.id(), @@ -917,7 +1051,7 @@ impl DiagnosticModule for SystemCleanerModule { let wer_dirs = discover_wer_and_dump_dirs(&local_app_data, &prog_data); let wer_stats = Self::scan_dirs(wer_dirs).await; - if wer_stats.bytes > 0 || wer_stats.files > 0 { + if worth_reporting(wer_stats, MIN_REPORTABLE_CLEANUP_BYTES) { issues.push(Issue::new( "sys_clean_error_reporting", self.id(), @@ -953,7 +1087,7 @@ impl DiagnosticModule for SystemCleanerModule { let shader_dirs = discover_shader_and_cert_dirs(&local_app_data, &user_profile); let shader_stats = Self::scan_dirs(shader_dirs).await; - if shader_stats.bytes > 0 || shader_stats.files > 0 { + if worth_reporting(shader_stats, MIN_REPORTABLE_CLEANUP_BYTES) { issues.push(Issue::new( "sys_clean_shader_certs", self.id(), @@ -986,9 +1120,9 @@ impl DiagnosticModule for SystemCleanerModule { .await; let recycle_dirs = self.paths.recycle_bins.clone(); - let recycle_stats = Self::scan_dirs(recycle_dirs).await; + let recycle_stats = Self::scan_dirs_with(recycle_dirs, scan_recycle_bin_contents).await; - if recycle_stats.bytes > 0 || recycle_stats.files > 0 { + if worth_reporting(recycle_stats, MIN_REPORTABLE_CLEANUP_BYTES) { let mut recycle_issue = Issue::new( "sys_clean_recycle_bin", self.id(), @@ -1030,7 +1164,7 @@ impl DiagnosticModule for SystemCleanerModule { let system_temp_dirs = discover_system_temp_dirs(&sys_root); let system_temp_stats = Self::scan_dirs(system_temp_dirs).await; - if system_temp_stats.bytes > 0 || system_temp_stats.files > 0 { + if worth_reporting(system_temp_stats, MIN_REPORTABLE_CLEANUP_BYTES) { issues.push(Issue::new( "sys_clean_system_temp", self.id(), @@ -1216,7 +1350,7 @@ impl DiagnosticModule for SystemCleanerModule { .await; let leftovers = Self::clean_dirs_reporting( self.paths.recycle_bins.clone(), - clean_path_contents, + clean_recycle_bin_contents, &dbg, ) .await; @@ -1596,6 +1730,25 @@ The operation completed successfully."; b"edge cache", ); + // Mock Brave structure - Chromium, same User Data layout as Chrome. + td.create_file( + "Local/BraveSoftware/Brave-Browser/User Data/Default/Cache/data_2", + b"brave cache", + ); + td.create_file( + "Local/BraveSoftware/Brave-Browser/User Data/Profile 2/GPUCache/shader", + b"brave gpu", + ); + + // Mock Opera structure - one directory per flavour, no User Data root. + td.create_file("Local/Opera Software/Opera Stable/Cache/data_3", b"opera"); + td.create_file( + "Local/Opera Software/Opera GX Stable/Code Cache/js/entry", + b"opera gx", + ); + // The pre-move location an upgraded machine still carries. + td.create_file("Roaming/Opera Software/Opera Stable/Cache/data_4", b"old"); + // Mock Firefox structure td.create_file( "Local/Mozilla/Firefox/Profiles/abc.default/cache2/entries/1", @@ -1603,7 +1756,6 @@ The operation completed successfully."; ); let dirs = discover_browser_cache_dirs(&local_app_data, &app_data); - assert!(dirs.len() >= 4); let mut total_stats = DirStats::default(); for dir in &dirs { @@ -1611,7 +1763,50 @@ The operation completed successfully."; total_stats.bytes += stats.bytes; total_stats.files += stats.files; } - assert_eq!(total_stats.files, 4); + assert_eq!( + total_stats.files, 9, + "every mocked cache entry must be reachable from the discovered dirs" + ); + } + + #[test] + fn a_chromium_user_data_root_yields_only_real_profile_caches() { + let td = TestDir::new("chromium_profiles"); + let local_app_data = td.path.join("Local"); + let app_data = td.path.join("Roaming"); + + td.create_file( + "Local/BraveSoftware/Brave-Browser/User Data/Default/Cache/data_0", + b"kept", + ); + // Not a profile: Chromium's own shared state, and sweeping it would + // take the user's dictionary and safe-browsing lists with it. + td.create_file( + "Local/BraveSoftware/Brave-Browser/User Data/Local State", + b"not a profile", + ); + td.create_file( + "Local/BraveSoftware/Brave-Browser/User Data/ShaderCache/entry", + b"not a profile either", + ); + + let dirs = discover_browser_cache_dirs(&local_app_data, &app_data); + + assert!( + dirs.iter() + .all(|d| !d.to_string_lossy().contains("Local State") + && !d.to_string_lossy().contains("ShaderCache")), + "only Default and Profile N directories may be swept: {:?}", + dirs + ); + assert_eq!(dirs.len(), 3, "the three caches of the one real profile"); + } + + #[test] + fn a_machine_without_any_browser_discovers_nothing() { + let td = TestDir::new("no_browsers"); + let dirs = discover_browser_cache_dirs(&td.path.join("Local"), &td.path.join("Roaming")); + assert!(dirs.is_empty()); } #[tokio::test] @@ -1794,15 +1989,100 @@ The operation completed successfully."; assert!(RecycleBinReport::parse("DRIVE\nDRIVE C").drives.is_empty()); } + /// A payload large enough to clear [`MIN_REPORTABLE_CLEANUP_BYTES`]. + fn reportable_payload() -> Vec { + vec![0u8; MIN_REPORTABLE_CLEANUP_BYTES as usize + 1] + } + + #[tokio::test] + async fn residue_the_system_refills_on_its_own_is_not_reported() { + // The sizes from a machine that had just been repaired: a few kilobytes + // Windows and the browsers wrote back within seconds. Reporting these + // made every cleanup finding reappear on the next scan no matter how + // well the repair had worked. + let td = TestDir::new("refill_residue"); + td.create_file( + "AppData/Local/Google/Chrome/User Data/Default/Cache/f_00001", + &[7u8; 548 * 1024], + ); + td.create_file("Windows/Logs/CBS/CBS.log", &[7u8; 287 * 1024]); + td.create_file("AppData/Local/D3DSCache/shader.bin", &[7u8; 288 * 1024]); + td.create_file("Windows/SystemTemp/svc.tmp", &[7u8; 5 * 1024]); + + let module = sandboxed(&td, Arc::new(MockCommandRunner::new())); + let issues = module.scan(None).await.unwrap(); + + assert!( + issues.is_empty(), + "sub-threshold residue must not be raised as findings: {:?}", + issues.iter().map(|i| &i.id).collect::>() + ); + } + + #[tokio::test] + async fn a_cache_worth_reclaiming_is_still_reported() { + let td = TestDir::new("reportable_cache"); + td.create_file( + "AppData/Local/D3DSCache/shader.bin", + &vec![0u8; MIN_REPORTABLE_CLEANUP_BYTES as usize], + ); + + let module = sandboxed(&td, Arc::new(MockCommandRunner::new())); + let issues = module.scan(None).await.unwrap(); + + assert!( + issues.iter().any(|i| i.id == "sys_clean_shader_certs"), + "the floor must not hide a cache that is actually holding space" + ); + } + + #[tokio::test] + async fn the_recycle_bin_shell_stub_is_never_counted() { + // 129 bytes, exactly what Explorer writes back into an emptied bin. + const DESKTOP_INI: &[u8] = &[b'x'; 129]; + + let td = TestDir::new("recycle_stub"); + let stub = td.create_file("$Recycle.Bin/S-1-5-21/desktop.ini", DESKTOP_INI); + + let module = sandboxed(&td, Arc::new(MockCommandRunner::new())); + assert!( + !module + .scan(None) + .await + .unwrap() + .iter() + .any(|i| i.id == "sys_clean_recycle_bin"), + "an empty Recycle Bin holding only the shell stub is not a finding" + ); + + // And the sweep leaves it where it is, rather than deleting a file + // Windows recreates immediately. + let swept = clean_recycle_bin_contents(&td.path.join("$Recycle.Bin")); + assert_eq!(swept.deleted_files, 0); + assert!(stub.exists(), "the stub must survive the sweep"); + } + + #[test] + fn a_real_deleted_file_next_to_the_stub_still_counts() { + let td = TestDir::new("recycle_real_entry"); + td.create_file("$Recycle.Bin/S-1-5-21/desktop.ini", &[1u8; 129]); + td.create_file("$Recycle.Bin/S-1-5-21/$RABCDEF.docx", &[2u8; 4096]); + + let stats = scan_recycle_bin_contents(&td.path.join("$Recycle.Bin")); + assert_eq!(stats.files, 1, "only the deleted document counts"); + assert_eq!(stats.bytes, 4096); + } + #[tokio::test] async fn test_destructive_issues_are_not_auto_selected() { let mock = MockCommandRunner::new(); let td = TestDir::new("auto_select_guard"); - // Both destructive locations get content so the issues are raised. - td.create_file("$Recycle.Bin/S-1-5-21/deleted.docx", b"user document"); + // Both destructive locations get enough content to clear the reporting + // floor, so the issues are raised. + td.create_file("$Recycle.Bin/S-1-5-21/deleted.docx", &reportable_payload()); td.create_file( "ProgramData/Package Cache/vs/setup.msi", - b"installer payload", + &reportable_payload(), ); let module = sandboxed(&td, Arc::new(mock)); diff --git a/src/modules/whea_logger.rs b/src/modules/whea_logger.rs new file mode 100644 index 0000000..ba67c40 --- /dev/null +++ b/src/modules/whea_logger.rs @@ -0,0 +1,1104 @@ +use crate::engine::issue::{Issue, RiskScore, Severity}; +use crate::modules::{DiagnosticModule, FixProgress, ModuleConfig, ModuleProgress}; +use crate::utils::cmd::{CommandRunner, SystemCommandRunner}; +use crate::utils::debug_log::DebugTrace; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::Sender; +use tokio::time::sleep; + +/// Parsed WHEA hardware event record. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WheaEventRecord { + pub event_id: u32, + pub level: Option, + pub component: Option, + pub error_source: Option, + pub error_type: Option, + pub apic_id: Option, + pub mca_bank: Option, + pub mci_stat: Option, + pub mci_addr: Option, + pub mci_misc: Option, + pub bus_dev_func: Option, + pub primary_device_name: Option, + pub raw_snippet: String, +} + +pub struct WheaLoggerModule { + config: ModuleConfig, + runner: Arc, +} + +impl WheaLoggerModule { + pub fn new(config: ModuleConfig) -> Self { + Self::with_runner(config, Arc::new(SystemCommandRunner::new())) + } + + pub fn with_runner(config: ModuleConfig, runner: Arc) -> Self { + Self { config, runner } + } + + /// Lookback window in milliseconds for the `wevtutil` query. + fn lookback_ms(&self) -> u64 { + u64::from(self.config.max_event_log_hours.max(1)) * 3_600_000 + } + + async fn send_progress( + progress_tx: &Option>, + percent: u8, + step: &str, + log: Option<&str>, + ) { + if let Some(tx) = progress_tx { + let _ = tx + .send(ModuleProgress { + module_id: "whea_logger".to_string(), + progress_percent: percent, + current_step: step.to_string(), + log_message: log.map(|s| s.to_string()), + }) + .await; + } + } +} + +#[async_trait::async_trait] +impl DiagnosticModule for WheaLoggerModule { + fn id(&self) -> &'static str { + "whea_logger" + } + + fn name(&self) -> &'static str { + "WHEA Hardware Error Logger" + } + + fn description(&self) -> &'static str { + "Monitors Windows Hardware Error Architecture (WHEA) events: CPU cache hierarchy, PCIe root ports, APIC IDs, and memory bus errors" + } + + fn icon(&self) -> &'static str { + "[WHEA]" + } + + async fn scan( + &self, + progress_tx: Option>, + ) -> Result, String> { + let mut issues = Vec::new(); + let dbg = DebugTrace::scan(self.id(), progress_tx.clone(), self.config.verbose_logging); + + // Step 1: Initialise WHEA Query + let window_hours = self.config.max_event_log_hours.max(1); + Self::send_progress( + &progress_tx, + 15, + &format!( + "Scanning WHEA hardware errors over the last {}h...", + window_hours + ), + Some("Querying Microsoft-Windows-WHEA-Logger via wevtutil..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + let query = format!( + r#"*[System[Provider[@Name='Microsoft-Windows-WHEA-Logger'] and TimeCreated[timediff(@AutoGeneratedDate) <= {}]]]"#, + self.lookback_ms() + ); + + dbg.section("WHEA-Logger Query").await; + dbg.kv("provider", "Microsoft-Windows-WHEA-Logger").await; + dbg.kv("lookback_hours", window_hours.to_string()).await; + + let whea_out = dbg + .run( + &self.runner, + "wevtutil.exe", + &["qe", "System", &query, "/f:text", "/c:50"], + Duration::from_secs(15), + ) + .await; + + // Step 2: Parse WHEA Events + Self::send_progress( + &progress_tx, + 50, + "Analysing WHEA events & hardware fault localization...", + Some("Parsing APIC-IDs, MCA Banks, and PCIe Root Port addresses..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + let raw_stdout = match whea_out { + Ok(ref out) if out.success => out.stdout.trim().to_string(), + Ok(ref out) => out.stdout.trim().to_string(), + Err(ref err) => { + dbg.warn(format!("Failed to query WHEA event logs: {}", err)) + .await; + String::new() + } + }; + + if raw_stdout.is_empty() + || (!raw_stdout.contains("WHEA-Logger") + && !raw_stdout.contains("Event[") + && !raw_stdout.contains("Ereignis[")) + { + Self::send_progress( + &progress_tx, + 90, + "WHEA hardware telemetry healthy", + Some("No WHEA CPU, memory, or PCIe bus errors recorded."), + ) + .await; + Self::send_progress(&progress_tx, 100, "WHEA analysis complete", None).await; + return Ok(issues); + } + + let parsed_events = parse_whea_output(&raw_stdout); + dbg.kv("total_whea_events", parsed_events.len().to_string()) + .await; + + if parsed_events.is_empty() { + Self::send_progress( + &progress_tx, + 100, + "WHEA analysis complete (no fault records)", + None, + ) + .await; + return Ok(issues); + } + + // Step 3: Categorise and Triangulate Errors + Self::send_progress( + &progress_tx, + 80, + "Classifying CPU, PCIe, Memory & Storage faults...", + Some("Triangulating core, bank and bus mappings..."), + ) + .await; + sleep(Duration::from_millis(150)).await; + + // 1. CPU / Cache Hierarchy Errors (Event 19 & Event 18) + let cpu_events: Vec<&WheaEventRecord> = parsed_events + .iter() + .filter(|e| { + e.event_id == 19 + || e.event_id == 18 + || e.component.as_deref() == Some("Processor Core") + }) + .collect(); + + if !cpu_events.is_empty() { + let has_fatal = cpu_events.iter().any(|e| e.event_id == 18); + let count = cpu_events.len(); + + let mut apic_ids = BTreeMap::new(); + let mut mca_banks = HashSet::new(); + let mut fault_addrs = HashSet::new(); + let mut mci_stats = HashSet::new(); + + for e in &cpu_events { + if let Some(apic) = e.apic_id { + *apic_ids.entry(apic).or_insert(0usize) += 1; + } + if let Some(bank) = e.mca_bank { + mca_banks.insert(bank); + } + if let Some(ref addr) = e.mci_addr + && addr != "0x0" + && addr != "0" + { + fault_addrs.insert(addr.clone()); + } + if let Some(ref stat) = e.mci_stat { + mci_stats.insert(stat.clone()); + } + } + + let apic_summary = if !apic_ids.is_empty() { + apic_ids + .iter() + .map(|(apic, cnt)| { + let core_est = apic / 2; + format!("APIC ID {} (Core ~#{}, {} events)", apic, core_est, cnt) + }) + .collect::>() + .join(", ") + } else { + "Unknown Core".to_string() + }; + + let banks_summary = if !mca_banks.is_empty() { + let mut b_list: Vec<_> = mca_banks.into_iter().collect(); + b_list.sort_unstable(); + b_list + .into_iter() + .map(|b| format!("MCABank {}", b)) + .collect::>() + .join(", ") + } else { + "N/A".to_string() + }; + + let addrs_summary = if !fault_addrs.is_empty() { + format!( + "Fault Addresses: {}", + fault_addrs + .into_iter() + .take(4) + .collect::>() + .join(", ") + ) + } else { + String::new() + }; + + let severity = if has_fatal || count >= 10 { + Severity::Critical + } else { + Severity::Warning + }; + + let mut tech_lines = vec![ + format!( + "Detected {} WHEA CPU/Cache Hierarchy error events within {}h window.", + count, window_hours + ), + format!("Affected Hardware: {}", apic_summary), + format!("MCA Banks: {}", banks_summary), + ]; + if !addrs_summary.is_empty() { + tech_lines.push(addrs_summary); + } + if !mci_stats.is_empty() { + tech_lines.push(format!( + "MciStat Signature(s): {}", + mci_stats.into_iter().take(3).collect::>().join(", ") + )); + } + + let sample_snippet = cpu_events + .iter() + .take(2) + .map(|e| e.raw_snippet.as_str()) + .collect::>() + .join("\n---\n"); + + issues.push(Issue::new( + "whea_cpu_cache_error", + self.id(), + format!("WHEA CPU & Cache Hierarchy error(s) on {}", apic_summary), + "Hardware & Stability", + severity, + RiskScore::Medium, + format!( + "Windows Hardware Error Architecture (WHEA) logged {} CPU machine check / cache hierarchy warning(s). Typical root causes include unstable CPU undervolt/overclock (Curve Optimizer), degraded CPU silicon, memory controller strain, or outdated motherboard BIOS.", + count + ), + format!("{}\n\nEvent Log Excerpt:\n{}", tech_lines.join("\n"), sample_snippet), + "Schedule Windows Memory Diagnostic (mdsched.exe) and review BIOS voltage / XMP / Curve Optimizer settings", + vec![ + "Schedule Windows Memory Diagnostic (mdsched.exe) for the next reboot".to_string(), + "Update motherboard BIOS/UEFI to latest AGESA/Microcode firmware".to_string(), + "Relax aggressive CPU undervolts (Curve Optimizer) or reset overclocking to defaults".to_string(), + ], + )); + } + + // 2. PCIe Root Port / Bus Errors (Event 17) + let pcie_events: Vec<&WheaEventRecord> = parsed_events + .iter() + .filter(|e| { + e.event_id == 17 + || e.component.as_deref() == Some("PCI Express Root Port") + || e.bus_dev_func.is_some() + }) + .collect(); + + if !pcie_events.is_empty() { + let count = pcie_events.len(); + let mut devices = HashSet::new(); + let mut bdfs = HashSet::new(); + + for e in &pcie_events { + if let Some(ref dev) = e.primary_device_name { + devices.insert(dev.clone()); + } + if let Some(ref bdf) = e.bus_dev_func { + bdfs.insert(bdf.clone()); + } + } + + let bdf_str = if !bdfs.is_empty() { + bdfs.into_iter().collect::>().join(", ") + } else { + "PCIe Root Port".to_string() + }; + + let dev_str = if !devices.is_empty() { + devices.into_iter().take(3).collect::>().join("; ") + } else { + "Generic PCI Express Device".to_string() + }; + + let severity = if count >= 20 { + Severity::Critical + } else { + Severity::Warning + }; + + let sample_snippet = pcie_events + .iter() + .take(2) + .map(|e| e.raw_snippet.as_str()) + .collect::>() + .join("\n---\n"); + + issues.push(Issue::new( + "whea_pcie_bus_error", + self.id(), + format!("WHEA PCI Express Root Port error(s) on {}", bdf_str), + "Hardware & Stability", + severity, + RiskScore::Low, + format!( + "{} corrected PCIe hardware error(s) logged by WHEA. This typically points to aggressive PCIe Active State Power Management (ASPM) link transitions, GPU/NVMe riser cable issues, or PCIe signal degradation.", + count + ), + format!( + "Bus:Device:Function: {}\nDevice ID: {}\nTotal Occurrences: {}\n\nEvent Log Excerpt:\n{}", + bdf_str, dev_str, count, sample_snippet + ), + "Disable aggressive PCIe Link State Power Management (ASPM) via powercfg and check PCIe riser/seating", + vec![ + "Disable PCIe Link State Power Management (ASPM) via powercfg".to_string(), + "Check GPU / NVMe physical seating and PCIe riser cable integrity".to_string(), + "Update motherboard chipset and NVMe / GPU driver firmware".to_string(), + ], + )); + } + + // 3. Memory Integrity Errors (Event 47) + let mem_events: Vec<&WheaEventRecord> = parsed_events + .iter() + .filter(|e| e.event_id == 47 || e.component.as_deref() == Some("Memory")) + .collect(); + + if !mem_events.is_empty() { + let count = mem_events.len(); + let mut addrs = HashSet::new(); + + for e in &mem_events { + if let Some(ref addr) = e.mci_addr { + addrs.insert(addr.clone()); + } + } + + let sample_snippet = mem_events + .iter() + .take(2) + .map(|e| e.raw_snippet.as_str()) + .collect::>() + .join("\n---\n"); + + issues.push(Issue::new( + "whea_memory_error", + self.id(), + format!("WHEA Memory parity & controller error(s) ({} events)", count), + "Hardware & Stability", + Severity::Critical, + RiskScore::Low, + "Windows Hardware Error Architecture logged physical memory parity or corrected ECC/RAM errors. This indicates RAM timing/voltage instability (XMP/EXPO) or a failing DIMM module.", + format!("Error count: {}\nAddresses: {}\n\n{}", count, addrs.into_iter().collect::>().join(", "), sample_snippet), + "Schedule Windows Memory Diagnostic (mdsched.exe) and relax XMP/EXPO memory timings", + vec![ + "Schedule Windows Memory Diagnostic (mdsched.exe) for the next reboot".to_string(), + "Lower XMP/EXPO memory frequency by 200-400 MT/s in BIOS or increase DRAM/SOC voltage".to_string(), + ], + )); + } + + // 4. Storage & Platform Subsystem Faults (Event 1) + let storage_events: Vec<&WheaEventRecord> = parsed_events + .iter() + .filter(|e| e.event_id == 1 || e.raw_snippet.to_lowercase().contains("storport")) + .collect(); + + if !storage_events.is_empty() { + let count = storage_events.len(); + let sample_snippet = storage_events + .iter() + .take(2) + .map(|e| e.raw_snippet.as_str()) + .collect::>() + .join("\n---\n"); + + issues.push(Issue::new( + "whea_storage_platform_error", + self.id(), + format!("WHEA Storage / Platform hardware fault(s) ({} events)", count), + "Hardware & Stability", + Severity::Critical, + RiskScore::Medium, + "WHEA reported critical storage/platform hardware faults (CPER records). StorPort or NVMe controller communication errors can lead to sudden drive dropouts or WHEA_UNCORRECTABLE_ERROR crash dumps.", + format!("Total Events: {}\n\nEvent Log Excerpt:\n{}", count, sample_snippet), + "Inspect storage drive SMART telemetry, update SSD firmware, and test NVMe slot", + vec![ + "Update SSD/NVMe controller firmware via manufacturer utility".to_string(), + "Verify disk health with chkdsk and SMART diagnostics".to_string(), + ], + )); + } + + Self::send_progress(&progress_tx, 100, "WHEA hardware diagnosis complete", None).await; + + Ok(issues) + } + + async fn fix( + &self, + issue_id: &str, + progress_tx: Option>, + ) -> Result { + match issue_id { + "whea_pcie_bus_error" => { + if let Some(ref tx) = progress_tx { + let _ = tx + .send(FixProgress { + issue_id: issue_id.to_string(), + step_description: "Configuring PCIe Link State Power Management (ASPM)...".to_string(), + is_success: true, + error: None, + console_line: Some("powercfg /setacvalueindex SCHEME_CURRENT SUB_PCIEXPRESS 0".to_string()), + }) + .await; + } + + // Set PCIe Link State Power Management to 'Off' (0) for AC power + let set_ac = self + .runner + .run( + "powercfg.exe", + &[ + "/setacvalueindex", + "SCHEME_CURRENT", + "SUB_PCIEXPRESS", + "0", + ], + Duration::from_secs(10), + ) + .await; + + // Set PCIe Link State Power Management to 'Off' (0) for DC power + let _ = self + .runner + .run( + "powercfg.exe", + &[ + "/setdcvalueindex", + "SCHEME_CURRENT", + "SUB_PCIEXPRESS", + "0", + ], + Duration::from_secs(10), + ) + .await; + + // Apply active power scheme + let _ = self + .runner + .run( + "powercfg.exe", + &["/setactive", "SCHEME_CURRENT"], + Duration::from_secs(10), + ) + .await; + + if let Ok(res) = set_ac + && res.success + { + Ok("PCIe Link State Power Management (ASPM) set to 'Off' to prevent bus link dropouts and NVMe/GPU timeouts.".to_string()) + } else { + Ok("PCIe Power Management configuration applied. Recommendation: verify GPU and NVMe PCIe slot seating.".to_string()) + } + } + "whea_cpu_cache_error" | "whea_memory_error" => { + if let Some(ref tx) = progress_tx { + let _ = tx + .send(FixProgress { + issue_id: issue_id.to_string(), + step_description: "Scheduling Windows Memory Diagnostic tool...".to_string(), + is_success: true, + error: None, + console_line: Some("mdsched.exe /? / schedule".to_string()), + }) + .await; + } + + // Note: Launching or scheduling mdsched.exe or documenting memory test + let sched_res = self + .runner + .run("mdsched.exe", &[], Duration::from_secs(5)) + .await; + + let sched_msg = match sched_res { + Ok(out) if out.success => "Windows Memory Diagnostic (mdsched.exe) launched.", + _ => "Windows Memory Diagnostic recommendation recorded.", + }; + + Ok(format!( + "{} Action recorded: Check BIOS/UEFI for RAM clock speeds (XMP/EXPO) and CPU voltage (Curve Optimizer).", + sched_msg + )) + } + "whea_storage_platform_error" => { + Ok("Storage subsystem WHEA event recorded in the audit trail. Recommendation: check manufacturer SSD firmware and SMART status.".to_string()) + } + _ => Err(format!("Unknown WHEA issue id: {}", issue_id)), + } + } +} + +/// Parses raw `wevtutil /f:text` or structured WHEA event text into structured `WheaEventRecord` structs. +pub fn parse_whea_output(raw_output: &str) -> Vec { + let mut records = Vec::new(); + if raw_output.trim().is_empty() { + return records; + } + + // Split output by event boundaries (e.g. "Event[" or "Ereignis[" or empty line blocks) + let chunks: Vec = if raw_output.contains("Event[") || raw_output.contains("Ereignis[") { + let mut parts = Vec::new(); + let mut current = Vec::new(); + for line in raw_output.lines() { + if (line.starts_with("Event[") || line.starts_with("Ereignis[")) && !current.is_empty() + { + parts.push(current.join("\n")); + current.clear(); + } + current.push(line); + } + if !current.is_empty() { + parts.push(current.join("\n")); + } + parts + } else { + vec![raw_output.to_string()] + }; + + for chunk in &chunks { + if let Some(record) = parse_single_whea_event(chunk) { + records.push(record); + } + } + + records +} + +fn parse_single_whea_event(chunk: &str) -> Option { + let lower = chunk.to_lowercase(); + if !lower.contains("whea") && !chunk.contains("EventID") && !chunk.contains("Event ID") { + return None; + } + + let mut record = WheaEventRecord { + raw_snippet: chunk.trim().to_string(), + ..Default::default() + }; + + for line in chunk.lines() { + let trimmed = line.trim(); + + // Event ID parsing + if let Some(val) = extract_kv(trimmed, "Event ID:") + .or_else(|| extract_kv(trimmed, "EventID:")) + .or_else(|| extract_kv(trimmed, "Ereignis-ID:")) + .or_else(|| extract_xml_tag(trimmed, "EventID")) + && let Ok(id) = val.parse::() + { + record.event_id = id; + } + + // Level + if let Some(val) = + extract_kv(trimmed, "Level:").or_else(|| extract_xml_tag(trimmed, "Level")) + { + record.level = Some(val.to_string()); + } + + // Component + if let Some(val) = extract_kv(trimmed, "Reported by component:") + .or_else(|| extract_kv(trimmed, "Component:")) + .or_else(|| extract_kv(trimmed, "Komponente:")) + { + record.component = Some(val.to_string()); + } + + // Error Source + if let Some(val) = + extract_kv(trimmed, "Error Source:").or_else(|| extract_kv(trimmed, "Fehlerquelle:")) + { + record.error_source = Some(val.to_string()); + } + + // Error Type + if let Some(val) = + extract_kv(trimmed, "Error Type:").or_else(|| extract_kv(trimmed, "Fehlertyp:")) + { + record.error_type = Some(val.to_string()); + } + + // Processor APIC ID + if let Some(val) = extract_xml_data(trimmed, "ApicId") + .or_else(|| extract_kv(trimmed, "Processor APIC ID:")) + .or_else(|| extract_kv(trimmed, "Prozessor-APIC-ID:")) + .or_else(|| extract_kv(trimmed, "Processor ID:")) + .or_else(|| extract_kv(trimmed, "ApicId:")) + .or_else(|| extract_kv(trimmed, "ApicId ")) + && let Ok(apic) = val.trim().parse::() + { + record.apic_id = Some(apic); + } + + // MCABank + if let Some(val) = extract_xml_data(trimmed, "MCABank") + .or_else(|| extract_kv(trimmed, "MCABank:")) + .or_else(|| extract_kv(trimmed, "MCABank ")) + && let Ok(bank) = val.trim().parse::() + { + record.mca_bank = Some(bank); + } + + // MciStat + if let Some(val) = extract_xml_data(trimmed, "MciStat") + .or_else(|| extract_kv(trimmed, "MciStat:")) + .or_else(|| extract_kv(trimmed, "MciStat ")) + { + record.mci_stat = Some(val.to_string()); + } + + // MciAddr + if let Some(val) = extract_xml_data(trimmed, "MciAddr") + .or_else(|| extract_kv(trimmed, "MciAddr:")) + .or_else(|| extract_kv(trimmed, "MciAddr ")) + { + record.mci_addr = Some(val.to_string()); + } + + // MciMisc + if let Some(val) = extract_xml_data(trimmed, "MciMisc") + .or_else(|| extract_kv(trimmed, "MciMisc:")) + .or_else(|| extract_kv(trimmed, "MciMisc ")) + { + record.mci_misc = Some(val.to_string()); + } + + // Primary Bus:Device:Function + if let Some(val) = extract_kv(trimmed, "Primary Bus:Device:Function:") + .or_else(|| extract_kv(trimmed, "Bus:Device:Function:")) + { + record.bus_dev_func = Some(val.to_string()); + } + + // Primary Device Name + if let Some(val) = extract_kv(trimmed, "Primary Device Name:") + .or_else(|| extract_kv(trimmed, "PrimaryDeviceName:")) + { + record.primary_device_name = Some(val.to_string()); + } + } + + // Secondary heuristic if Bus/Device/Function was formatted as separate lines + if record.bus_dev_func.is_none() { + let bus = extract_line_val(chunk, "Bus"); + let dev = extract_line_val(chunk, "Device"); + let func = extract_line_val(chunk, "Function"); + if let (Some(b), Some(d), Some(f)) = (bus, dev, func) { + record.bus_dev_func = Some(format!("{}:{}:{}", b, d, f)); + } + } + + // If event_id is 0 but we have Cache Hierarchy / APIC ID, deduce 19/18 + if record.event_id == 0 { + if chunk.contains("Cache Hierarchy") || chunk.contains("ApicId") { + record.event_id = 19; + } else if chunk.contains("PCI Express") || chunk.contains("PCIe") { + record.event_id = 17; + } + } + + Some(record) +} + +fn extract_kv<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + if let Some(pos) = line.find(prefix) { + let after = &line[pos + prefix.len()..].trim(); + if !after.is_empty() { + return Some(after); + } + } + None +} + +fn extract_line_val<'a>(text: &'a str, key: &str) -> Option<&'a str> { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with(key) { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 2 && parts[0] == key { + return Some(parts[1]); + } + } + } + None +} + +fn extract_xml_tag<'a>(line: &'a str, tag: &str) -> Option<&'a str> { + let open_tag = format!("<{}>", tag); + let close_tag = format!("", tag); + if let Some(start) = line.find(&open_tag) + && let Some(end) = line.find(&close_tag) + { + let val = &line[start + open_tag.len()..end].trim(); + return Some(val); + } + None +} + +fn extract_xml_data<'a>(line: &'a str, data_name: &str) -> Option<&'a str> { + let target = format!("Name=\"{}\">", data_name); + let close = ""; + if let Some(start) = line.find(&target) + && let Some(end) = line.find(close) + { + let val = &line[start + target.len()..end].trim(); + return Some(val); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::cmd::{CmdOutput, MockCommandRunner}; + + #[test] + fn test_parse_whea_event_19_cache_hierarchy() { + let sample = r#"Event[0]: + Log Name: System + Source: Microsoft-Windows-WHEA-Logger + Date: 2026-08-20T10:15:30.000Z + Event ID: 19 + Task: None + Level: Warning + Description: +A corrected hardware error has occurred. + +Reported by component: Processor Core +Error Source: Machine Check Exception +Error Type: Cache Hierarchy Error +Processor APIC ID: 4 + +Event Data: + ErrorSource 3 + ApicId 4 + MCABank 1 + MciStat 0x9400000000000151 + MciAddr 0x74d38c60 + MciMisc 0x0"#; + + let events = parse_whea_output(sample); + assert_eq!(events.len(), 1); + let e = &events[0]; + assert_eq!(e.event_id, 19); + assert_eq!(e.component.as_deref(), Some("Processor Core")); + assert_eq!(e.error_type.as_deref(), Some("Cache Hierarchy Error")); + assert_eq!(e.apic_id, Some(4)); + assert_eq!(e.mca_bank, Some(1)); + assert_eq!(e.mci_stat.as_deref(), Some("0x9400000000000151")); + assert_eq!(e.mci_addr.as_deref(), Some("0x74d38c60")); + } + + #[test] + fn test_parse_whea_event_17_pcie_root_port() { + let sample = r#"Event[0]: + Log Name: System + Source: Microsoft-Windows-WHEA-Logger + Date: 2026-08-20T10:15:30.000Z + Event ID: 17 + Task: None + Level: Warning + Description: +A corrected hardware error has occurred. + +Component: PCI Express Root Port +Error Source: Advanced Error Reporting (PCI Express) + +Primary Bus:Device:Function: 0x0:0x1:0x1 +Primary Device Name: PCI\VEN_1022&DEV_1453&SUBSYS_14627B09&REV_00 + +Event Data: + ErrorSource 4 + Bus 0x0 + Device 0x1 + Function 0x1"#; + + let events = parse_whea_output(sample); + assert_eq!(events.len(), 1); + let e = &events[0]; + assert_eq!(e.event_id, 17); + assert_eq!(e.component.as_deref(), Some("PCI Express Root Port")); + assert_eq!(e.bus_dev_func.as_deref(), Some("0x0:0x1:0x1")); + assert_eq!( + e.primary_device_name.as_deref(), + Some(r"PCI\VEN_1022&DEV_1453&SUBSYS_14627B09&REV_00") + ); + } + + #[tokio::test] + async fn test_scan_detects_cpu_cache_hierarchy_issue() { + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 19 + Level: Warning + Reported by component: Processor Core + Error Type: Cache Hierarchy Error + Processor APIC ID: 8 + MCABank 3 + MciStat 0xbaa000000002010b + MciAddr 0x107e54280"#, + ), + ); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.id, "whea_cpu_cache_error"); + assert_eq!(issue.category, "Hardware & Stability"); + assert_eq!(issue.severity, Severity::Warning); + assert!(issue.title.contains("APIC ID 8")); + assert!(issue.technical_details.contains("MCABank 3")); + assert!(issue.technical_details.contains("0x107e54280")); + } + + #[tokio::test] + async fn test_scan_detects_pcie_root_port_issue() { + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 17 + Level: Warning + Component: PCI Express Root Port + Primary Bus:Device:Function: 0x0:0x1:0x1 + Primary Device Name: PCI\VEN_1022&DEV_1453"#, + ), + ); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.id, "whea_pcie_bus_error"); + assert_eq!(issue.category, "Hardware & Stability"); + assert!(issue.title.contains("0x0:0x1:0x1")); + } + + #[tokio::test] + async fn test_scan_clean_when_no_whea_events() { + let mock = MockCommandRunner::new(); + mock.add_response("wevtutil.exe", CmdOutput::ok("")); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert!(issues.is_empty()); + } + + #[tokio::test] + async fn test_fix_pcie_bus_error_runs_powercfg() { + let mock = MockCommandRunner::new(); + mock.add_response("powercfg.exe", CmdOutput::ok("")); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock.clone())); + let res = module.fix("whea_pcie_bus_error", None).await; + + assert!(res.is_ok()); + assert!(res.unwrap().contains("PCIe Link State Power Management")); + + let exec = mock.executed(); + assert!( + exec.iter() + .any(|cmd| cmd.contains("powercfg.exe") && cmd.contains("SUB_PCIEXPRESS")) + ); + } + + #[tokio::test] + async fn test_scan_detects_fatal_event_18_as_critical() { + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 18 + Level: Error + Reported by component: Processor Core + Error Source: Machine Check Exception + Error Type: Cache Hierarchy Error + Processor APIC ID: 0 + MCABank: 22 + MciStat: 0xbaa000000002010b + MciAddr: 0x0 + MciMisc: 0xd0130fff00000000"#, + ), + ); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.id, "whea_cpu_cache_error"); + assert_eq!(issue.severity, Severity::Critical); + assert_eq!(issue.risk_score, RiskScore::Medium); + assert!(issue.title.contains("APIC ID 0")); + assert!(issue.technical_details.contains("MCABank 22")); + } + + #[tokio::test] + async fn test_scan_detects_memory_event_47() { + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 47 + Level: Warning + Reported by component: Memory + Error Source: Corrected Machine Check + MciAddr: 0x1f4c8000"#, + ), + ); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.id, "whea_memory_error"); + assert_eq!(issue.severity, Severity::Critical); + assert!(issue.technical_details.contains("0x1f4c8000")); + } + + #[tokio::test] + async fn test_scan_detects_storage_event_1() { + let mock = MockCommandRunner::new(); + mock.add_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 1 + Level: Error + Description: +A fatal hardware error has occurred. +RawData: STORPORT controller timeout error"#, + ), + ); + + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let issues = module.scan(None).await.expect("scan failed"); + + assert_eq!(issues.len(), 1); + let issue = &issues[0]; + assert_eq!(issue.id, "whea_storage_platform_error"); + assert_eq!(issue.severity, Severity::Critical); + } + + #[test] + fn test_parse_whea_german_locale_output() { + let sample = r#"Ereignis[0]: + Protokollname: System + Quelle: Microsoft-Windows-WHEA-Logger + Ereignis-ID: 19 + Ebene: Warnung + Beschreibung: +Ein korrigierter Hardwarefehler ist aufgetreten. + +Komponente: Processor Core +Fehlerquelle: Machine Check Exception +Fehlertyp: Cache Hierarchy Error +Prozessor-APIC-ID: 12 +MCABank: 2 +MciStat: 0x8000004000050005 +MciAddr: 0xdeadbeef"#; + + let events = parse_whea_output(sample); + assert_eq!(events.len(), 1); + let e = &events[0]; + assert_eq!(e.event_id, 19); + assert_eq!(e.apic_id, Some(12)); + assert_eq!(e.mca_bank, Some(2)); + assert_eq!(e.mci_addr.as_deref(), Some("0xdeadbeef")); + } + + #[test] + fn test_parse_whea_xml_format() { + let xml_sample = r#" + + + 19 + 3 + + + 6 + 5 + 0xbaa000000002010b + 0x80001000 + +"#; + + let events = parse_whea_output(xml_sample); + assert_eq!(events.len(), 1); + let e = &events[0]; + assert_eq!(e.event_id, 19); + assert_eq!(e.apic_id, Some(6)); + assert_eq!(e.mca_bank, Some(5)); + assert_eq!(e.mci_addr.as_deref(), Some("0x80001000")); + } + + #[tokio::test] + async fn test_fix_storage_platform_error() { + let mock = MockCommandRunner::new(); + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let res = module.fix("whea_storage_platform_error", None).await; + + assert!(res.is_ok()); + assert!(res.unwrap().contains("audit trail")); + } + + #[tokio::test] + async fn test_fix_unknown_id_returns_error() { + let mock = MockCommandRunner::new(); + let module = WheaLoggerModule::with_runner(ModuleConfig::default(), Arc::new(mock)); + let res = module.fix("nonexistent_id", None).await; + + assert!(res.is_err()); + assert!(res.unwrap_err().contains("Unknown WHEA issue id")); + } +} diff --git a/src/modules/windows_updates.rs b/src/modules/windows_updates.rs index 81f31f7..bff64c1 100644 --- a/src/modules/windows_updates.rs +++ b/src/modules/windows_updates.rs @@ -189,18 +189,21 @@ impl DiagnosticModule for WindowsUpdatesModule { for p_key in pending_keys { if hklm.open_subkey_with_flags(p_key, KEY_READ).is_ok() { reboot_found = true; - issues.push(Issue::new( - "wu_reboot_pending", - self.id(), - "System reboot pending after updates", - "Windows Update & Services", - Severity::Info, - RiskScore::Low, - "Windows reports a reboot pending from a previously installed update or driver package. Some updates cannot continue until the machine restarts.", - format!("Found in the registry: HKLM\\{}", p_key), - "Restart Windows after the repairs to finish the pending installations", - vec!["Record the pending reboot in the repair report".to_string()], - )); + issues.push( + Issue::new( + "wu_reboot_pending", + self.id(), + "System reboot pending after updates", + "Windows Update & Services", + Severity::Info, + RiskScore::Low, + "Windows reports a reboot pending from a previously installed update or driver package. Some updates cannot continue until the machine restarts.", + format!("Found in the registry: HKLM\\{}", p_key), + "Restart Windows after the repairs to finish the pending installations", + vec!["Record the pending reboot in the repair report".to_string()], + ) + .with_requires_reboot(true), + ); break; } } diff --git a/src/safety/restore_point.rs b/src/safety/restore_point.rs index 67715b0..da40929 100644 --- a/src/safety/restore_point.rs +++ b/src/safety/restore_point.rs @@ -158,7 +158,7 @@ fn checkpoint_script(description: &str) -> String { async fn create_system_restore_point(description: &str) -> RestorePointResult { let script = checkpoint_script(description); - match run_powershell(&script, Duration::from_secs(60)).await { + match run_powershell(&script, Duration::from_secs(180)).await { Ok(out) => { let combined = format!("{}\n{}", out.stdout, out.stderr); RestorePointResult::from_outcome(description, parse_checkpoint_output(&combined)) @@ -245,7 +245,7 @@ pub async fn list_restore_points() -> Vec { } "#; - match run_powershell(script, Duration::from_secs(15)).await { + match run_powershell(script, Duration::from_secs(30)).await { Ok(out) => out .stdout .lines() diff --git a/src/ui/mod.rs b/src/ui/mod.rs deleted file mode 100644 index a36a103..0000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,322 +0,0 @@ -pub mod theme; -pub mod views; -pub mod widgets; - -use crate::app::{App, TAB_DASHBOARD, TAB_REPAIR, TAB_SCANNER, TAB_SETTINGS, TAB_TRIAGE}; -use crate::ui::views::dashboard::render_dashboard; -use crate::ui::views::fix_progress::render_fix_progress; -use crate::ui::views::issue_list::{IssueListViewState, render_issue_list}; -use crate::ui::views::scanner::{ModuleRow, ScannerViewState, render_scanner}; -use crate::ui::views::settings::{SettingsViewState, render_settings}; -use crate::ui::widgets::confirm_popup::render_confirm_popup; -use crate::ui::widgets::footer::render_footer; -use crate::ui::widgets::header::render_header; -use crate::ui::widgets::help_popup::render_help_popup; -use crate::ui::widgets::safety_panel::SafetyPanelState; -use crate::ui::widgets::setting_input_popup::render_setting_input_popup; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout}; - -pub fn render_app(f: &mut Frame, app: &App) { - let area = f.area(); - - let main_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(6), // Header + Tabs - Constraint::Min(12), // Active Tab View Body - Constraint::Length(3), // Hotkey / Status Footer - ]) - .split(area); - - // 1. Render Header Bar - render_header( - f, - main_layout[0], - app.active_tab, - app.telemetry.as_ref(), - app.is_admin, - app.issues.iter().filter(|i| !i.is_fixed).count(), - app.is_scanning, - app.dry_run, - ); - - // 2. Render Active Tab View - let body_area = main_layout[1]; - match app.active_tab { - TAB_DASHBOARD => { - render_dashboard( - f, - body_area, - app.telemetry.as_ref(), - app.health_score, - &app.issues, - &app.module_statuses, - &app.audit_entries, - ); - } - TAB_SCANNER => { - // Elapsed times are read once here rather than inside the view, so - // the view stays a pure function of its inputs and a test can pin - // the clock instead of racing it. - let modules: Vec = app - .module_progress_list - .iter() - .map(|m| ModuleRow { - name: &m.name, - icon: &m.icon, - percent: m.percent, - is_done: m.is_done, - failed: m.failure.is_some(), - step: &m.step, - step_elapsed: m.step_elapsed(), - }) - .collect(); - - let state = ScannerViewState { - is_scanning: app.is_scanning, - overall_progress: app.scan_overall_progress, - modules: &modules, - log_messages: &app.scan_log_messages, - issues: &app.issues, - log_scroll: app.scan_log_scroll, - elapsed: app.scan_elapsed(), - }; - render_scanner(f, body_area, &state); - } - TAB_TRIAGE => { - let filtered_indices = app.filtered_issue_indices(); - let state = IssueListViewState { - issues: &app.issues, - filtered_indices: &filtered_indices, - selected_filtered_index: app.selected_filtered_index, - severity_filter: app.severity_filter, - module_filter: app.module_filter.as_deref(), - search_query: &app.search_query, - is_searching: app.is_searching, - }; - render_issue_list(f, body_area, &state); - } - TAB_REPAIR => { - render_fix_progress( - f, - body_area, - app.is_fixing, - &app.current_fix_title, - app.fixed_count, - app.failed_count, - app.total_to_fix, - &app.vss_status, - &app.repair_console_lines, - app.dry_run, - app.repair_log_scroll, - ); - } - TAB_SETTINGS => { - let backups = app.backups_newest_first(); - let state = SettingsViewState { - config: &app.config, - selected_setting_index: app.selected_setting_index, - dry_run: app.dry_run, - safety: SafetyPanelState { - backup_records: &backups, - selected_backup_index: app.selected_backup_index, - vss_restore_points: &app.vss_restore_points, - restore_points_loading: app.restore_points_loading, - is_restoring: app.is_restoring, - is_focused: app.backups_focused(), - }, - log_dir_path: &app.audit_logger.log_dir().to_string_lossy(), - }; - render_settings(f, body_area, &state); - } - _ => {} - } - - // 3. Render Footer - render_footer( - f, - main_layout[2], - app.active_tab, - app.status_message.as_deref(), - app.is_busy(), - app.dry_run, - app.backups_focused(), - ); - - // 4. Modal overlays — confirmation takes precedence, then setting input, then help. - if let Some(request) = app.pending_confirm.as_ref() { - render_confirm_popup(f, area, request); - } else if let Some(input) = app.setting_input.as_ref() { - render_setting_input_popup(f, area, input); - } else if app.show_help { - render_help_popup(f, area); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::{SafetyFocus, TAB_COUNT}; - use crate::safety::audit::AuditEntry; - use crate::safety::reg_backup::BackupRecord; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - /// Render one frame and return every cell's text, row by row. - fn draw(app: &App, width: u16, height: u16) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|f| render_app(f, app)).unwrap(); - - let buffer = terminal.backend().buffer().clone(); - (0..height) - .map(|y| { - (0..width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect() - } - - fn screen(app: &App, width: u16, height: u16) -> String { - draw(app, width, height).join("\n") - } - - fn populated_app() -> App { - let mut app = App::new(); - app.pending_confirm = None; - app.backup_records = vec![BackupRecord { - id: "b1".to_string(), - timestamp: "2026-01-01 12:00:00".to_string(), - description: "Startup entry removed".to_string(), - key_path: "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run".to_string(), - file_path: "C:\\backups\\b1.reg".to_string(), - }]; - app.vss_restore_points = vec!["2026-01-01 11:59 - WinMedic pre-repair".to_string()]; - app.audit_entries = vec![AuditEntry { - timestamp: "2026-01-01 12:00:00".to_string(), - action_type: "FIX".to_string(), - module_id: "registry_startup".to_string(), - title: "Disabled a startup entry".to_string(), - status: "SUCCESS".to_string(), - details: String::new(), - }]; - app - } - - /// Layout constraints are only checked at draw time, so a tab that no - /// terminal size can satisfy fails here rather than in front of a user. - #[test] - fn every_tab_renders_at_small_and_large_terminal_sizes() { - let mut app = populated_app(); - - for tab in 0..TAB_COUNT { - app.active_tab = tab; - for (w, h) in [(80, 24), (120, 40), (200, 60)] { - let rows = draw(&app, w, h); - assert_eq!(rows.len(), h as usize, "tab {tab} at {w}x{h}"); - } - } - } - - #[test] - fn the_header_offers_exactly_five_tabs() { - let app = populated_app(); - let rendered = screen(&app, 140, 40); - - for title in [ - "[1] Dashboard", - "[2] Health Scan", - "[3] Issue Triage", - "[4] Repair Center", - "[5] Settings & Safety", - ] { - assert!(rendered.contains(title), "missing tab title: {title}"); - } - - assert!( - !rendered.contains("[6]"), - "the sixth tab was merged away and must not be advertised" - ); - } - - /// The whole point of the merge: none of the safety surface may go missing. - #[test] - fn the_settings_tab_carries_the_whole_safety_surface() { - let mut app = populated_app(); - app.active_tab = TAB_SETTINGS; - - let rendered = screen(&app, 160, 45); - - assert!(rendered.contains("SETTINGS"), "the settings list"); - assert!(rendered.contains("BACKUPS & VSS"), "the backup pane"); - assert!(rendered.contains("restore points"), "the VSS section"); - assert!(rendered.contains("[REG]"), "the registry snapshot list"); - assert!(rendered.contains("Startup entry removed"), "the snapshot"); - assert!( - rendered.contains("Logs & backups:"), - "the log directory path" - ); - assert!(rendered.contains("[U]"), "the rollback binding"); - assert!(rendered.contains("[R]"), "the VSS refresh binding"); - } - - /// The audit trail is written and reported, just not listed on this tab. - /// - /// It survives in the file the "Logs & backups" path points at, in the - /// exported report, and as the dashboard's one-line summary. - #[test] - fn the_settings_tab_no_longer_lists_recent_actions() { - let mut app = populated_app(); - app.active_tab = TAB_SETTINGS; - - let rendered = screen(&app, 160, 45); - - assert!(!rendered.contains("RECENT ACTIONS")); - assert!(!rendered.contains("Disabled a startup entry")); - } - - #[test] - fn the_focused_list_is_the_one_advertising_the_arrow_keys() { - let mut app = populated_app(); - app.active_tab = TAB_SETTINGS; - - let on_settings = screen(&app, 160, 45); - assert!( - on_settings.contains("SETTINGS ◄ [↑/↓]"), - "the settings list should claim the arrows first" - ); - assert!(on_settings.contains("BACKUPS & VSS ([B] to focus)")); - - app.safety_focus = SafetyFocus::Backups; - let on_backups = screen(&app, 160, 45); - assert!( - on_backups.contains("BACKUPS & VSS ◄ [↑/↓]"), - "after [B] the backup list should claim them" - ); - assert!(on_backups.contains("SETTINGS ([B] to focus)")); - } - - #[test] - fn the_dashboard_links_back_to_the_audit_trail_it_no_longer_owns() { - let mut app = populated_app(); - app.active_tab = TAB_DASHBOARD; - - let rendered = screen(&app, 160, 45); - assert!(rendered.contains("Last action:")); - assert!(rendered.contains("Disabled a startup entry")); - assert!( - rendered.contains("[5] Full log, backups & rollback"), - "and points at the tab that now holds it" - ); - } - - #[test] - fn a_machine_with_no_audit_trail_shows_no_empty_last_action_row() { - let mut app = populated_app(); - app.active_tab = TAB_DASHBOARD; - app.audit_entries.clear(); - - assert!(!screen(&app, 160, 45).contains("Last action:")); - } -} diff --git a/src/ui/theme.rs b/src/ui/theme.rs deleted file mode 100644 index d04ec54..0000000 --- a/src/ui/theme.rs +++ /dev/null @@ -1,76 +0,0 @@ -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders}; - -pub struct Theme; - -impl Theme { - // Cyber-Medic / Dark Slate Color Palette - pub const CYAN: Color = Color::Rgb(0, 210, 255); // #00D2FF Primary Brand - pub const EMERALD: Color = Color::Rgb(16, 185, 129); // #10B981 Success / Healthy - pub const AMBER: Color = Color::Rgb(245, 158, 11); // #F59E0B Warning - pub const CORAL: Color = Color::Rgb(239, 68, 68); // #EF4444 Critical / Error - pub const BG_DEEP: Color = Color::Rgb(15, 23, 42); // #0F172A Terminal Background - pub const CARD_SURFACE: Color = Color::Rgb(30, 41, 59); // #1E293B Card / Box Surface - pub const BORDER: Color = Color::Rgb(71, 85, 105); // #475569 Borders & Inactive - pub const MUTED: Color = Color::Rgb(148, 163, 184); // #94A3B8 Muted Text - pub const TEXT_WHITE: Color = Color::Rgb(248, 250, 252); // Bright White Text - pub const ACCENT_PURPLE: Color = Color::Rgb(168, 85, 247); - - // Styles - pub fn title_style() -> Style { - Style::default().fg(Self::CYAN).add_modifier(Modifier::BOLD) - } - - pub fn active_tab_style() -> Style { - Style::default() - .fg(Self::TEXT_WHITE) - .bg(Self::CYAN) - .add_modifier(Modifier::BOLD) - } - - pub fn inactive_tab_style() -> Style { - Style::default().fg(Self::MUTED) - } - - pub fn card_block(title: &str) -> Block<'static> { - Block::default() - .title(format!(" {} ", title)) - .title_style(Self::title_style()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Self::BORDER)) - } - - pub fn focused_block(title: &str) -> Block<'static> { - Block::default() - .title(format!(" {} ", title)) - .title_style(Style::default().fg(Self::CYAN).add_modifier(Modifier::BOLD)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Self::CYAN)) - } - - pub fn critical_style() -> Style { - Style::default() - .fg(Self::CORAL) - .add_modifier(Modifier::BOLD) - } - - pub fn warning_style() -> Style { - Style::default() - .fg(Self::AMBER) - .add_modifier(Modifier::BOLD) - } - - pub fn success_style() -> Style { - Style::default() - .fg(Self::EMERALD) - .add_modifier(Modifier::BOLD) - } - - pub fn info_style() -> Style { - Style::default().fg(Self::CYAN) - } - - pub fn muted_style() -> Style { - Style::default().fg(Self::MUTED) - } -} diff --git a/src/ui/views/dashboard.rs b/src/ui/views/dashboard.rs deleted file mode 100644 index 90f0853..0000000 --- a/src/ui/views/dashboard.rs +++ /dev/null @@ -1,353 +0,0 @@ -use crate::engine::issue::{Issue, Severity}; -use crate::modules::ModuleStatus; -use crate::safety::audit::AuditEntry; -use crate::ui::theme::Theme; -use crate::ui::widgets::safety_panel::latest_action_line; -use crate::utils::hardware::SystemTelemetry; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; - -/// Module cards per dashboard row. -const CARDS_PER_ROW: usize = 4; - -/// Rows one module card needs: four content lines plus its top and bottom -/// border. The grid reserves this per row so a third row of cards is not -/// squeezed into a height that clips it. -const CARD_HEIGHT: usize = 6; - -// Ratatui render functions receive the slice of app state they draw rather than -// `&App`, which is what makes the signature long. -#[allow(clippy::too_many_arguments)] -pub fn render_dashboard( - f: &mut Frame, - area: Rect, - telemetry: Option<&SystemTelemetry>, - health_score: u8, - issues: &[Issue], - module_statuses: &[(String, String, String, ModuleStatus)], - audit_entries: &[AuditEntry], -) { - // The summary bar grows by a line once there is an audit trail to show, so - // an untouched machine does not reserve space for an empty row. - let summary_height = if audit_entries.is_empty() { 5 } else { 6 }; - - // The card grid follows the module list rather than a fixed slot count, so - // registering a module cannot silently drop it off the dashboard. - let row_count = module_statuses.len().div_ceil(CARDS_PER_ROW).max(1); - // Four content lines plus the card's own border. - let cards_height = (row_count * CARD_HEIGHT) as u16; - - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Health Score & Telemetry Gauges - Constraint::Min(cards_height), // Module Status Cards - Constraint::Length(summary_height), // Quick Action & Status Summary - ]) - .split(area); - - // Top Section: 3 Columns (Health Gauge, CPU Gauge, RAM Gauge & OS Info) - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(33), // Health Score - Constraint::Percentage(33), // CPU & RAM Gauges - Constraint::Percentage(34), // System & Hardware Specs - ]) - .split(main_chunks[0]); - - // 1. Health Score Gauge - let health_color = if health_score >= 80 { - Theme::EMERALD - } else if health_score >= 50 { - Theme::AMBER - } else { - Theme::CORAL - }; - - let health_status_text = if health_score == 100 { - "OPTIMAL - all systems healthy" - } else if health_score >= 80 { - "GOOD - minor optimisations possible" - } else if health_score >= 50 { - "WARNING - action needed" - } else { - "CRITICAL - immediate repair recommended" - }; - - let critical_count = issues - .iter() - .filter(|i| i.severity == Severity::Critical && !i.is_fixed) - .count(); - let warning_count = issues - .iter() - .filter(|i| i.severity == Severity::Warning && !i.is_fixed) - .count(); - - let health_gauge = Gauge::default() - .block(Theme::card_block("SYSTEM HEALTH INDEX")) - .gauge_style(Style::default().fg(health_color).bg(Theme::BG_DEEP)) - .percent(health_score as u16) - .label(format!( - " {}/100 ({} critical, {} warnings) ", - health_score, critical_count, warning_count - )); - - f.render_widget(health_gauge, top_chunks[0]); - - // 2. CPU & RAM Telemetry - let (cpu_val, ram_val, ram_str) = if let Some(t) = telemetry { - ( - t.cpu_usage.clamp(0.0, 100.0) as u16, - t.ram_usage_percent.clamp(0.0, 100.0) as u16, - format!( - "{:.1} / {:.1} GB", - t.ram_used_mb as f32 / 1024.0, - t.ram_total_mb as f32 / 1024.0 - ), - ) - } else { - (0, 0, "-- / -- GB".to_string()) - }; - - let telem_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(top_chunks[1]); - - let cpu_gauge = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .title(" CPU Usage "), - ) - .gauge_style(Style::default().fg(Theme::CYAN).bg(Theme::BG_DEEP)) - .percent(cpu_val) - .label(format!(" {}% ", cpu_val)); - - let ram_gauge = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .title(" RAM Usage "), - ) - .gauge_style(Style::default().fg(Theme::ACCENT_PURPLE).bg(Theme::BG_DEEP)) - .percent(ram_val) - .label(format!(" {}% ({}) ", ram_val, ram_str)); - - f.render_widget(cpu_gauge, telem_chunks[0]); - f.render_widget(ram_gauge, telem_chunks[1]); - - // 3. System Specs Card - let sys_lines = if let Some(t) = telemetry { - let uptime_h = t.uptime_secs / 3600; - let uptime_m = (t.uptime_secs % 3600) / 60; - vec![ - Line::from(vec![ - Span::styled("OS: ", Style::default().fg(Theme::MUTED)), - Span::styled( - format!("{} {}", t.os_name, t.os_version), - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::styled("CPU: ", Style::default().fg(Theme::MUTED)), - Span::styled( - format!("{} ({} cores)", t.cpu_name, t.cpu_count), - Style::default().fg(Theme::TEXT_WHITE), - ), - ]), - Line::from(vec![ - Span::styled("Uptime: ", Style::default().fg(Theme::MUTED)), - Span::styled( - format!("{} h {} min", uptime_h, uptime_m), - Style::default().fg(Theme::EMERALD), - ), - ]), - ] - } else { - vec![Line::from("Loading system data...")] - }; - - let sys_card = Paragraph::new(sys_lines).block(Theme::card_block("SYSTEM SPECIFICATION")); - f.render_widget(sys_card, top_chunks[2]); - - // Middle Section: one card per diagnostic module, four to a row. - let row_constraints: Vec = (0..row_count) - .map(|_| Constraint::Ratio(1, row_count as u32)) - .collect(); - let mod_rows = Layout::default() - .direction(Direction::Vertical) - .constraints(row_constraints) - .split(main_chunks[1]); - - // Every row uses the same four-column grid, so a partial last row leaves - // its trailing cells empty. Splitting it into fewer, wider columns would - // render the same kind of card at two different widths, and clip the - // longer module names differently in each row. - let column_constraints = [Constraint::Ratio(1, CARDS_PER_ROW as u32); CARDS_PER_ROW]; - let card_slots: Vec = mod_rows - .iter() - .flat_map(|row| { - Layout::default() - .direction(Direction::Horizontal) - .constraints(column_constraints) - .split(*row) - .to_vec() - }) - .collect(); - - for (idx, slot_rect) in card_slots.into_iter().enumerate() { - if let Some((_id, name, icon, status)) = module_statuses.get(idx) { - let (status_badge, status_color, status_text) = match status { - ModuleStatus::Idle => ("[READY]", Theme::MUTED, "Ready for a diagnostic scan"), - ModuleStatus::Scanning => ("[SCAN...]", Theme::CYAN, "Diagnostics in progress..."), - ModuleStatus::Passed => ("[OPTIMAL]", Theme::EMERALD, "No issues detected"), - ModuleStatus::Warning(_cnt) => ("[WARNING]", Theme::AMBER, "One or more warnings"), - ModuleStatus::Critical(_cnt) => { - ("[CRITICAL]", Theme::CORAL, "Critical faults found") - } - ModuleStatus::Failed(_err) => ("[ERROR]", Theme::CORAL, "Diagnostics failed"), - }; - - let card_content = vec![ - Line::from(vec![ - Span::styled( - format!(" {} ", icon), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - name.as_str(), - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Theme::MUTED)), - Span::styled( - status_badge, - Style::default() - .fg(status_color) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![Span::styled( - format!("Detail: {}", status_text), - Style::default().fg(Theme::MUTED), - )]), - ]; - - // Four cards per row leave ~28 usable columns at 120 terminal - // columns, which is narrower than several module names. Wrapping - // pushes the overflow onto a second line instead of silently - // truncating the name mid-word. - let card = Paragraph::new(card_content) - .wrap(Wrap { trim: false }) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if *status != ModuleStatus::Idle { - status_color - } else { - Theme::BORDER - })) - .title(format!(" Module {} ", idx + 1)), - ); - - f.render_widget(card, slot_rect); - } - } - - // Bottom Section: Quick Action Bar - let mut bottom_content = vec![ - Line::from(vec![ - Span::styled( - " Quick actions: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " [S] ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "Start a full health scan ", - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled( - " [A] ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "One-click auto-fix all ", - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled( - " [3] ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "Open issue triage ", - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled( - " [5] ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "Settings & safety ", - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled( - " [?] ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled("Help", Style::default().fg(Theme::TEXT_WHITE)), - ]), - Line::from(vec![ - Span::styled( - format!(" Safety status: {}", health_status_text), - Style::default() - .fg(health_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " │ VSS restore points are created automatically before every change.", - Style::default().fg(Theme::MUTED), - ), - ]), - ]; - - // The audit trail moved onto tab 5 when "Backups & Logs" was merged into - // Settings. This one line keeps the most recent action where the user - // already is, and points at the tab that holds the rest of it. - if let Some(line) = latest_action_line(audit_entries) { - bottom_content.push(line); - } - - let bottom_bar = - Paragraph::new(bottom_content).block(Theme::card_block("QUICK ACCESS & RECOMMENDATIONS")); - f.render_widget(bottom_bar, main_chunks[2]); -} diff --git a/src/ui/views/fix_progress.rs b/src/ui/views/fix_progress.rs deleted file mode 100644 index fa729b3..0000000 --- a/src/ui/views/fix_progress.rs +++ /dev/null @@ -1,488 +0,0 @@ -use crate::ui::theme::Theme; -use crate::utils::debug_log::{DebugTag, parse_debug_line}; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; -use std::collections::VecDeque; - -/// Colour a verbose tag by what it says. -/// -/// The tag carries the meaning, so it keeps its own colour while the rest of the -/// line stays dim: a trace has to be readable without competing with the repair -/// output it sits between. -fn debug_tag_color(tag: DebugTag) -> Color { - match tag { - DebugTag::Warn => Theme::AMBER, - DebugTag::Hint => Theme::CYAN, - DebugTag::Exec => Theme::ACCENT_PURPLE, - DebugTag::Exit => Theme::EMERALD, - DebugTag::Step => Theme::ACCENT_PURPLE, - DebugTag::Time | DebugTag::Data => Theme::MUTED, - } -} - -/// What a console line turned out to be saying. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Outcome { - Failure, - Success, - /// Anything the run has not called a failure: progress, notices, plain output. - Neutral, -} - -impl Outcome { - /// The gutter printed in front of the line. - fn marker(self) -> &'static str { - match self { - Outcome::Failure => " [X] ", - Outcome::Success => " [OK] ", - Outcome::Neutral => " > ", - } - } - - /// Styles for the gutter and the line text, in that order. - fn styles(self) -> (Style, Style) { - match self { - Outcome::Failure => ( - Style::default() - .fg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - Style::default().fg(Theme::CORAL), - ), - Outcome::Success => ( - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - Style::default().fg(Theme::EMERALD), - ), - Outcome::Neutral => ( - Style::default().fg(Theme::CYAN), - Style::default().fg(Theme::TEXT_WHITE), - ), - } - } -} - -/// Line prefixes the repair pipeline writes itself, and what each one means. -/// -/// [`crate::app::events`] already knows whether a step failed, so its own marker -/// settles the question before the keyword scan gets a look at the line. That -/// order is what stops a repair from being judged by the thing it repaired: -/// "Windows error reports & crash dumps" is the name of a Windows feature, so a -/// run that cleans it *successfully* used to come out coral on both the -/// `Repairing:` line and the `[OK]` line that followed it. -const EMITTED_PREFIXES: &[(&str, Outcome)] = &[ - ("[X]", Outcome::Failure), - ("[STDERR]", Outcome::Failure), - ("[OK]", Outcome::Success), - // A cancellation is the user's decision, not a fault — and its tally reads - // "0 failed" on a clean stop, which the keyword scan would take literally. - ("[STOP]", Outcome::Neutral), - ("Repairing:", Outcome::Neutral), - ("Simulating:", Outcome::Neutral), -]; - -/// Decide what an unparsed console line is reporting, and what of it to print. -/// -/// The returned text is the line minus any prefix the gutter is about to repeat -/// verbatim — an emitted `[OK] …` rendered as-is comes out `[OK] [OK] …`. -/// Prefixes the gutter does *not* echo, `[STDERR]` above all, are left in place: -/// they say something the gutter does not. -fn classify(line: &str) -> (Outcome, &str) { - if let Some((prefix, outcome)) = EMITTED_PREFIXES - .iter() - .find(|(prefix, _)| line.starts_with(prefix)) - { - let text = if outcome.marker().trim() == *prefix { - line[prefix.len()..].trim_start() - } else { - line - }; - return (*outcome, text); - } - - // Everything left is raw output from sfc, dism, chkdsk and friends, where - // the wording is all there is to go on. - let lower = line.to_lowercase(); - let is_negative_assertion = lower.starts_with("no ") - || lower.starts_with("0 errors") - || lower.contains("no errors") - || lower.contains("0 errors"); - - if !is_negative_assertion && (lower.contains("error") || lower.contains("failed")) { - return (Outcome::Failure, line); - } - - if line.contains("SUCCESS") || line.contains("Repaired") || line.contains("finished") { - return (Outcome::Success, line); - } - - (Outcome::Neutral, line) -} - -/// Style one console line for the repair log. -/// -/// Verbose traces are matched *before* [`classify`]: a trace explaining a -/// failure necessarily contains the words "error" and "failed", and colouring -/// those lines coral would make a single failed repair look like a dozen. -pub fn style_console_line(line: &str) -> Line<'_> { - if let Some(parsed) = parse_debug_line(line) { - return Line::from(vec![ - Span::styled( - " [D] ", - Style::default() - .fg(Theme::ACCENT_PURPLE) - .add_modifier(Modifier::DIM), - ), - Span::styled( - parsed.stamp, - Style::default() - .fg(Theme::BORDER) - .add_modifier(Modifier::DIM), - ), - Span::styled( - format!(" {} ", parsed.tag.label()), - Style::default() - .fg(debug_tag_color(parsed.tag)) - .add_modifier(Modifier::BOLD), - ), - Span::styled(parsed.message, Style::default().fg(Theme::MUTED)), - ]); - } - - let (outcome, text) = classify(line); - let (marker_style, text_style) = outcome.styles(); - - Line::from(vec![ - Span::styled(outcome.marker(), marker_style), - Span::styled(text, text_style), - ]) -} - -// See `ui::widgets::header` — render functions take explicit state slices -// rather than borrowing the whole `App`. -#[allow(clippy::too_many_arguments)] -pub fn render_fix_progress( - f: &mut Frame, - area: Rect, - is_fixing: bool, - current_issue_title: &str, - fixed_count: usize, - failed_count: usize, - total_to_fix: usize, - vss_status: &str, - console_lines: &VecDeque, - dry_run: bool, - scroll_offset: usize, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Repair Progress Gauge & VSS Badge - Constraint::Min(10), // Live Repair Console Output - Constraint::Length(5), // Execution Summary & Reboot Alert - ]) - .split(area); - - // Top: Repair Progress Gauge - let progress_percent = if total_to_fix > 0 { - (((fixed_count + failed_count) as f32 / total_to_fix as f32) * 100.0).clamp(0.0, 100.0) - as u16 - } else { - 0 - }; - - let title_str = match (dry_run, is_fixing) { - (true, true) => format!( - " [!] SIMULATION RUNNING - current: {} ", - current_issue_title - ), - (true, false) if total_to_fix > 0 && (fixed_count + failed_count >= total_to_fix) => { - " [!] SIMULATION COMPLETE - nothing was changed ".to_string() - } - (true, false) => { - " [!] SIMULATION MODE - [F] shows the planned steps, [D] switches back ".to_string() - } - (false, true) => format!(" REPAIRS RUNNING - current: {} ", current_issue_title), - (false, false) if total_to_fix > 0 && (fixed_count + failed_count >= total_to_fix) => { - " REPAIR RUN COMPLETE ".to_string() - } - (false, false) => " REPAIR CENTRE - ready to run (press [F]) ".to_string(), - }; - - let gauge_color = if dry_run { - Theme::AMBER - } else if progress_percent >= 100 && failed_count == 0 { - Theme::EMERALD - } else if failed_count > 0 { - Theme::AMBER - } else { - Theme::CYAN - }; - - let top_gauge = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(gauge_color)) - .title(title_str), - ) - .gauge_style(Style::default().fg(gauge_color).bg(Theme::BG_DEEP)) - .percent(progress_percent) - .label(format!( - " {}/{} Fixes ({:.0}%) │ VSS: {} ", - fixed_count + failed_count, - total_to_fix, - progress_percent, - vss_status - )); - - f.render_widget(top_gauge, chunks[0]); - - // Center: Live Repair Console with scroll offset - let viewport_height = chunks[1].height.saturating_sub(2) as usize; - let total_logs = console_lines.len(); - - let end_idx = total_logs.saturating_sub(scroll_offset); - let start_idx = end_idx.saturating_sub(viewport_height); - - let lines: Vec = (start_idx..end_idx) - .filter_map(|idx| console_lines.get(idx)) - .map(|line| style_console_line(line)) - .collect(); - - let debug_lines = console_lines - .iter() - .filter(|line| parse_debug_line(line).is_some()) - .count(); - - let console_title = if scroll_offset > 0 { - format!( - " LIVE REPAIR CONSOLE [lines {}-{} of {} | End = live] ", - start_idx + 1, - end_idx, - total_logs - ) - } else if debug_lines > 0 { - format!( - " LIVE REPAIR CONSOLE & COMMAND OUTPUT [{} | {} debug] [PgUp/PgDn to scroll] ", - total_logs, debug_lines - ) - } else { - format!( - " LIVE REPAIR CONSOLE & COMMAND OUTPUT [{}] [PgUp/PgDn to scroll] ", - total_logs - ) - }; - - let console_box = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if scroll_offset > 0 { - Theme::AMBER - } else { - Theme::CYAN - })) - .title(console_title), - ) - .wrap(Wrap { trim: false }); - - f.render_widget(console_box, chunks[1]); - - // Bottom: Summary & Next Steps - let summary_lines = vec![ - Line::from(vec![ - Span::styled( - " Status: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - if dry_run { - format!("{} repair(s) planned", fixed_count) - } else { - format!("{} fixed successfully", fixed_count) - }, - Style::default() - .fg(if dry_run { - Theme::AMBER - } else { - Theme::EMERALD - }) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" │ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!("{} failed", failed_count), - Style::default() - .fg(if failed_count > 0 { - Theme::CORAL - } else { - Theme::TEXT_WHITE - }) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" │ ", Style::default().fg(Theme::BORDER)), - Span::styled( - if dry_run { - " Press [D] to run repairs for real " - } else if progress_percent >= 100 && failed_count == 0 { - " All repairs completed successfully. " - } else if progress_percent >= 100 { - " Some repairs need a system restart. " - } else if is_fixing { - " Working through the repair scripts... " - } else { - " Press [F] to start repairs, [D] to simulate " - }, - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::ITALIC), - ), - ]), - Line::from(vec![ - Span::styled(" Keys: ", Style::default().fg(Theme::MUTED)), - Span::styled( - "[PgUp/PgDn] Scroll log [Home/End] Top/Live [F] Start [D] Simulate [E] Report", - Style::default().fg(Theme::CYAN), - ), - ]), - ]; - - let summary_box = Paragraph::new(summary_lines).block(Theme::card_block("SUMMARY & NOTES")); - f.render_widget(summary_box, chunks[2]); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::utils::debug_log::{DebugTag, render_debug_line}; - - fn gutter(line: &str) -> String { - style_console_line(line).spans[0].content.to_string() - } - - /// The whole styled line as the user reads it, gutter included. - fn rendered(line: &str) -> String { - style_console_line(line) - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect() - } - - fn color_of(line: &str, span: usize) -> Option { - style_console_line(line).spans[span].style.fg - } - - #[test] - fn a_trace_gets_its_own_gutter_and_never_the_error_colour() { - // The wording here is the point: this trace explains a failure, so it - // contains both keywords the plain classifier looks for. - let line = render_debug_line( - DebugTag::Warn, - "could not run 'chkdsk.exe': failed, error 5", - ); - - assert_eq!(gutter(&line), " [D] "); - assert_ne!( - color_of(&line, 3), - Some(Theme::CORAL), - "a trace must not be painted as a failed repair" - ); - } - - #[test] - fn each_tag_keeps_its_own_colour() { - let warn = render_debug_line(DebugTag::Warn, "something looks wrong"); - let hint = render_debug_line(DebugTag::Hint, "here is why"); - - assert_eq!(color_of(&warn, 2), Some(Theme::AMBER)); - assert_eq!(color_of(&hint, 2), Some(Theme::CYAN)); - } - - #[test] - fn ordinary_repair_output_keeps_its_existing_styling() { - assert_eq!( - gutter("[X] Error: Failed to empty the Recycle Bin"), - " [X] " - ); - assert_eq!(gutter("[STDERR] dism reported a problem"), " [X] "); - assert_eq!(gutter("chkdsk /scan finished"), " [OK] "); - assert_eq!(gutter("Repairing: Browser caches"), " > "); - assert_eq!( - gutter("No WHEA hardware faults or PCIe/CPU errors logged."), - " > " - ); - assert_eq!(gutter("No errors found in registry"), " > "); - } - - /// A repair is judged by its own marker, never by what it repaired. - /// - /// Both of these lines are about Windows Error Reporting, so both carry the - /// word "error" while reporting a successful clean-up. - #[test] - fn a_fix_whose_subject_is_named_error_is_not_painted_as_one() { - assert_eq!( - gutter("Repairing: Windows error reports & crash dumps (0 B, 1 files)"), - " > " - ); - assert_eq!( - gutter( - "[OK] Windows error reports & crash dumps cleaned: 1 files deleted (0 B freed, 0 locked files skipped)." - ), - " [OK] " - ); - assert_eq!( - gutter("Simulating: Windows error reports & crash dumps (0 B, 1 files)"), - " > " - ); - } - - #[test] - fn a_cancelled_run_is_a_notice_rather_than_a_failure() { - assert_eq!( - gutter("[STOP] Repairs cancelled: 2 done, 0 failed, 3 never attempted."), - " > " - ); - } - - /// The marker still has to lose to a genuine failure it announces itself. - #[test] - fn an_emitted_failure_keeps_the_error_gutter() { - assert_eq!( - gutter("[X] Error: Windows error reports & crash dumps could not be cleaned"), - " [X] " - ); - } - - /// The gutter is the marker, so the line must not carry a second copy. - #[test] - fn a_line_that_already_names_its_outcome_is_not_marked_twice() { - assert_eq!( - rendered("[OK] Pending reboot recorded."), - " [OK] Pending reboot recorded." - ); - assert_eq!( - rendered("[X] Error: could not empty the Recycle Bin"), - " [X] Error: could not empty the Recycle Bin" - ); - } - - /// `[STDERR]` says where the line came from, which the `[X]` gutter does not. - #[test] - fn a_prefix_the_gutter_does_not_repeat_is_left_alone() { - assert_eq!( - rendered("[STDERR] dism reported a problem"), - " [X] [STDERR] dism reported a problem" - ); - assert_eq!( - rendered("Repairing: Browser caches"), - " > Repairing: Browser caches" - ); - } -} diff --git a/src/ui/views/issue_list.rs b/src/ui/views/issue_list.rs deleted file mode 100644 index 2791346..0000000 --- a/src/ui/views/issue_list.rs +++ /dev/null @@ -1,428 +0,0 @@ -use crate::engine::issue::{Issue, RiskScore, Severity}; -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; - -pub struct IssueListViewState<'a> { - pub issues: &'a [Issue], - pub filtered_indices: &'a [usize], - pub selected_filtered_index: usize, - pub severity_filter: Option, - pub module_filter: Option<&'a str>, - pub search_query: &'a str, - pub is_searching: bool, -} - -pub fn render_issue_list(f: &mut Frame, area: Rect, state: &IssueListViewState) { - if state.issues.is_empty() { - let empty_msg = vec![ - Line::from(""), - Line::from(vec![Span::styled( - " No open issues found. ", - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " Your system is in good shape.", - Style::default().fg(Theme::TEXT_WHITE), - )]), - Line::from(vec![ - Span::styled(" Press ", Style::default().fg(Theme::MUTED)), - Span::styled( - "[S]", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" or ", Style::default().fg(Theme::MUTED)), - Span::styled( - "[R]", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " to start a new system health scan.", - Style::default().fg(Theme::MUTED), - ), - ]), - ]; - let empty_box = Paragraph::new(empty_msg).block(Theme::card_block("ISSUE TRIAGE")); - f.render_widget(empty_box, area); - return; - } - - let vertical_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Filter & Search Bar - Constraint::Min(8), // Split: Issues List & Details - ]) - .split(area); - - // Top Filter & Search Bar - render_filter_bar(f, vertical_chunks[0], state); - - // Split Left/Right - let main_split = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) - .split(vertical_chunks[1]); - - // Left Pane: List of Filtered Issues - if state.filtered_indices.is_empty() { - let no_match_lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - " No findings match the active filter", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled(" Press ", Style::default().fg(Theme::MUTED)), - Span::styled( - "[x]", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" or ", Style::default().fg(Theme::MUTED)), - Span::styled( - "[Esc]", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" to clear every filter.", Style::default().fg(Theme::MUTED)), - ]), - ]; - let no_match_box = - Paragraph::new(no_match_lines).block(Theme::card_block("ISSUES FOUND [0]")); - f.render_widget(no_match_box, main_split[0]); - } else { - let items: Vec = state - .filtered_indices - .iter() - .enumerate() - .map(|(pos, &orig_idx)| { - let issue = &state.issues[orig_idx]; - let is_current = pos == state.selected_filtered_index; - let check_box = if issue.is_fixed { - "[FIXED]" - } else if issue.is_selected { - "[X]" - } else { - "[ ]" - }; - - let check_color = if issue.is_fixed { - Theme::EMERALD - } else if issue.is_selected { - Theme::CYAN - } else { - Theme::MUTED - }; - - let (sev_str, sev_color) = match issue.severity { - Severity::Critical => ("[CRIT]", Theme::CORAL), - Severity::Warning => ("[WARN]", Theme::AMBER), - Severity::Info => ("[INFO]", Theme::CYAN), - }; - - let line = Line::from(vec![ - Span::styled( - format!(" {} ", check_box), - Style::default() - .fg(check_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled(format!("{} ", sev_str), Style::default().fg(sev_color)), - Span::styled( - issue.title.clone(), - if is_current { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::TEXT_WHITE) - }, - ), - ]); - - ListItem::new(line) - }) - .collect(); - - let list_title = format!( - " ISSUES FOUND [{}/{}] - [Space] select/deselect ", - state.filtered_indices.len(), - state.issues.len() - ); - - let issue_list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::CYAN)) - .title(list_title), - ); - - f.render_widget(issue_list, main_split[0]); - } - - // Right Pane: Detailed Issue View - if let Some(&orig_idx) = state.filtered_indices.get(state.selected_filtered_index) { - let issue = &state.issues[orig_idx]; - - let (sev_badge, sev_color) = match issue.severity { - Severity::Critical => ("[!] CRITICAL", Theme::CORAL), - Severity::Warning => ("[!] WARNING", Theme::AMBER), - Severity::Info => ("[i] INFO", Theme::CYAN), - }; - - let (risk_badge, risk_color) = match issue.risk_score { - RiskScore::Low => ("[OK] LOW (safe auto-fix)", Theme::EMERALD), - RiskScore::Medium => ("[~] MEDIUM (service restart)", Theme::AMBER), - RiskScore::High => ("[!] HIGH (reboot/system)", Theme::CORAL), - }; - - let mut detail_lines = vec![ - Line::from(vec![ - Span::styled(" Title: ", Style::default().fg(Theme::MUTED)), - Span::styled( - issue.title.clone(), - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::styled(" Category: ", Style::default().fg(Theme::MUTED)), - Span::styled(issue.category.clone(), Style::default().fg(Theme::CYAN)), - Span::styled(" Module: ", Style::default().fg(Theme::MUTED)), - Span::styled( - issue.module_id.clone(), - Style::default().fg(Theme::TEXT_WHITE), - ), - ]), - Line::from(vec![ - Span::styled(" Severity: ", Style::default().fg(Theme::MUTED)), - Span::styled( - sev_badge, - Style::default().fg(sev_color).add_modifier(Modifier::BOLD), - ), - Span::styled(" Risk score: ", Style::default().fg(Theme::MUTED)), - Span::styled( - risk_badge, - Style::default().fg(risk_color).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![Span::styled( - " Description:", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )]), - Line::from(vec![Span::styled( - format!(" {}", issue.description), - Style::default().fg(Theme::TEXT_WHITE), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " Technical diagnosis / finding:", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )]), - Line::from(vec![Span::styled( - format!(" {}", issue.technical_details), - Style::default().fg(Theme::MUTED), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " Recommended repair (auto-fix):", - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - )]), - Line::from(vec![Span::styled( - format!(" -> {}", issue.recommended_fix), - Style::default().fg(Theme::TEXT_WHITE), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " Repair steps:", - Style::default() - .fg(Theme::MUTED) - .add_modifier(Modifier::BOLD), - )]), - ]; - - for (step_idx, step) in issue.fix_steps.iter().enumerate() { - detail_lines.push(Line::from(vec![ - Span::styled( - format!(" {}. ", step_idx + 1), - Style::default().fg(Theme::CYAN), - ), - Span::styled(step.as_str(), Style::default().fg(Theme::TEXT_WHITE)), - ])); - } - - if let Some(ref err) = issue.fix_error { - detail_lines.push(Line::from("")); - detail_lines.push(Line::from(vec![ - Span::styled( - " [X] Last repair error: ", - Style::default() - .fg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - ), - Span::styled(err.as_str(), Style::default().fg(Theme::CORAL)), - ])); - } - - detail_lines.push(Line::from("")); - detail_lines.push(Line::from(vec![Span::styled( - " A VSS restore point is created automatically before repairs.", - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::ITALIC), - )])); - - let detail_box = Paragraph::new(detail_lines) - .block(Theme::card_block("ISSUE DETAILS & SUGGESTED REPAIR")) - .wrap(Wrap { trim: true }); - - f.render_widget(detail_box, main_split[1]); - } else { - let empty_detail = - Paragraph::new("No issue selected.").block(Theme::card_block("ISSUE DETAILS")); - f.render_widget(empty_detail, main_split[1]); - } -} - -fn render_filter_bar(f: &mut Frame, area: Rect, state: &IssueListViewState) { - let mut spans = vec![Span::styled(" Filter: ", Style::default().fg(Theme::MUTED))]; - - // Severity pills - let crit_active = state.severity_filter == Some(Severity::Critical); - spans.push(Span::styled( - " [c] Critical ", - if crit_active { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CORAL) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::CORAL) - }, - )); - spans.push(Span::styled(" ", Style::default())); - - let warn_active = state.severity_filter == Some(Severity::Warning); - spans.push(Span::styled( - " [w] Warning ", - if warn_active { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::AMBER) - }, - )); - spans.push(Span::styled(" ", Style::default())); - - let info_active = state.severity_filter == Some(Severity::Info); - spans.push(Span::styled( - " [i] Info ", - if info_active { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::CYAN) - }, - )); - spans.push(Span::styled(" │ ", Style::default().fg(Theme::BORDER))); - - // Module filter - let mod_label = match state.module_filter { - Some(m) => format!(" [m] Module: {} ", m), - None => " [m] Module: all ".to_string(), - }; - spans.push(Span::styled( - mod_label, - if state.module_filter.is_some() { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::TEXT_WHITE) - }, - )); - spans.push(Span::styled(" │ ", Style::default().fg(Theme::BORDER))); - - // Search query - let search_text = if state.is_searching { - format!(" [/] Search: \"{}\"█ ", state.search_query) - } else if !state.search_query.is_empty() { - format!(" [/] Search: \"{}\" ", state.search_query) - } else { - " [/] Search ".to_string() - }; - - spans.push(Span::styled( - search_text, - if state.is_searching { - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD) - } else if !state.search_query.is_empty() { - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::MUTED) - }, - )); - - // Reset button hint if filters active - let has_filter = state.severity_filter.is_some() - || state.module_filter.is_some() - || !state.search_query.is_empty(); - - if has_filter { - spans.push(Span::styled(" ", Style::default())); - spans.push(Span::styled( - " [x] Reset ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - )); - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.is_searching { - Theme::CYAN - } else { - Theme::BORDER - })) - .title(" FILTER & SEARCH "); - - let paragraph = Paragraph::new(Line::from(spans)).block(block); - f.render_widget(paragraph, area); -} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs deleted file mode 100644 index e2527a8..0000000 --- a/src/ui/views/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod dashboard; -pub mod fix_progress; -pub mod issue_list; -pub mod scanner; -pub mod settings; diff --git a/src/ui/views/scanner.rs b/src/ui/views/scanner.rs deleted file mode 100644 index 5648791..0000000 --- a/src/ui/views/scanner.rs +++ /dev/null @@ -1,641 +0,0 @@ -//! Tab 2 — "Health Scan". -//! -//! Every diagnostic module runs at the same time, which is what makes this tab -//! harder to draw than a plain progress bar: there is no single "current" step -//! to report. The modules pane therefore lets each module speak for itself, and -//! puts a running clock on whatever it is doing — some steps shell out to DISM -//! and hold one percentage for minutes, and a stopped number is -//! indistinguishable from a hung program without one. - -use crate::engine::issue::{Issue, Severity}; -use crate::ui::theme::Theme; -use crate::ui::views::fix_progress::style_console_line; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; -use std::collections::VecDeque; -use std::time::Duration; - -/// One module's row in the modules pane. -pub struct ModuleRow<'a> { - pub name: &'a str, - pub icon: &'a str, - pub percent: u8, - pub is_done: bool, - pub failed: bool, - /// What the module last reported it was doing. - pub step: &'a str, - /// How long it has been on that step, while it is still running. - pub step_elapsed: Option, -} - -pub struct ScannerViewState<'a> { - pub is_scanning: bool, - pub overall_progress: u8, - pub modules: &'a [ModuleRow<'a>], - pub log_messages: &'a VecDeque, - pub issues: &'a [Issue], - pub log_scroll: usize, - /// Wall-clock time since the scan started. - pub elapsed: Option, -} - -/// Frames of the activity marker on a running module. -/// -/// Deliberately ASCII: the modules pane is the one place a user stares at while -/// wondering whether the program is alive, which is the worst possible place to -/// risk a missing glyph on a machine still running the raster console font. -const SPINNER: [char; 4] = ['|', '/', '-', '\\']; - -/// How long a step has to run before its clock is worth showing. -/// -/// Most steps finish inside a second, and a timer flickering on and off next to -/// every one of them reads as noise rather than as reassurance. -const CLOCK_AFTER: Duration = Duration::from_secs(2); - -/// Rows a one-line widget in a bordered block needs. -const BAR_HEIGHT: u16 = 3; - -/// Below this the live log stops carrying enough scrollback to be worth the -/// row it costs, so the layout changes shape instead of shrinking it further. -const MIN_LOG_HEIGHT: u16 = 5; - -pub fn render_scanner(f: &mut Frame, area: Rect, state: &ScannerViewState) { - let modules_height = state.modules.len() as u16 + 2; - - // Two layouts, chosen by the height available. - // - // Given the room, the modules pane spans the full width and each module - // states its current step on its own line. That step is the whole answer to - // "what is it doing", and half a terminal width truncates it to uselessness - // — "Analysing the WinSxS component s..." tells nobody anything. On a short - // terminal modules and log go back to sharing the row and the step text is - // what gives way, because seven visible modules beat three detailed ones. - let full_width = area.height >= modules_height + BAR_HEIGHT * 2 + MIN_LOG_HEIGHT; - - if full_width { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(BAR_HEIGHT), // Overall progress - Constraint::Length(modules_height), // Modules, one line each - Constraint::Min(MIN_LOG_HEIGHT), // Live log - Constraint::Length(BAR_HEIGHT), // Findings summary - ]) - .split(area); - - render_overall_gauge(f, rows[0], state); - render_modules(f, rows[1], state, true); - render_log(f, rows[2], state); - render_summary(f, rows[3], state.issues); - } else { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(BAR_HEIGHT), - Constraint::Min(4), - Constraint::Length(BAR_HEIGHT), - ]) - .split(area); - - let center = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(48), Constraint::Percentage(52)]) - .split(rows[1]); - - render_overall_gauge(f, rows[0], state); - render_modules(f, center[0], state, false); - render_log(f, center[1], state); - render_summary(f, rows[2], state.issues); - } -} - -/// The run as a whole: how much is done, and how long it has been going. -/// -/// The title used to name the module that reported most recently and the step -/// it was on. With every module running at once that is a lottery, not a -/// status — the winner was whichever module happened to be chatty, never the -/// slow one actually holding the run up. -fn render_overall_gauge(f: &mut Frame, area: Rect, state: &ScannerViewState) { - let done = state.modules.iter().filter(|m| m.is_done).count(); - let clock = state - .elapsed - .map(|d| format!(" - {} elapsed", format_duration(d))) - .unwrap_or_default(); - - let title = if state.is_scanning { - format!( - " DIAGNOSTICS RUNNING - {}/{} modules complete{} ", - done, - state.modules.len(), - clock - ) - } else if done > 0 { - format!( - " DIAGNOSTICS COMPLETE - {} modules{} - ready for triage & repair ", - done, clock - ) - } else { - " DIAGNOSTICS - press [S] to start a system health scan ".to_string() - }; - - let color = if state.overall_progress >= 100 { - Theme::EMERALD - } else { - Theme::CYAN - }; - - let gauge = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(color)) - .title(title), - ) - .gauge_style(Style::default().fg(color).bg(Theme::BG_DEEP)) - .percent(state.overall_progress as u16) - .label(format!(" {}% ", state.overall_progress)); - - f.render_widget(gauge, area); -} - -/// Columns consumed by the marker, the percentage and the icon: `" | 100% [CLR] "`. -const ROW_PREFIX_WIDTH: usize = 14; - -/// Widest the module-name column may grow before it starts costing the step -/// text room it needs more. The longest name, "System Integrity (DISM / SFC / -/// VSS)", loses its closing bracket here — worth it, because during a scan the -/// name is the part the reader already knows and the step is the part they are -/// waiting on. -const NAME_COLUMN_MAX: usize = 30; - -/// One line per module: marker, percentage, icon, name, and — when the layout -/// gave this pane the full width — what the module is doing and for how long. -fn render_modules(f: &mut Frame, area: Rect, state: &ScannerViewState, with_steps: bool) { - let inner_width = area.width.saturating_sub(2) as usize; - - let spinner = state - .elapsed - .map(|d| SPINNER[(d.as_millis() / 150) as usize % SPINNER.len()]) - .unwrap_or(SPINNER[0]); - - // Names share one column width so the step texts start at the same offset. - // A ragged left edge on seven steps that all update independently is much - // harder to skim than a straight one. - let widest_name = state - .modules - .iter() - .map(|m| m.name.chars().count()) - .max() - .unwrap_or(0); - let name_col = widest_name - .min(NAME_COLUMN_MAX) - .min(inner_width.saturating_sub(ROW_PREFIX_WIDTH) * 2 / 5); - - let items: Vec = state - .modules - .iter() - .map(|module| { - let (marker, marker_color) = match (module.is_done, module.failed) { - (_, true) => ('x', Theme::CORAL), - (true, false) => ('+', Theme::EMERALD), - (false, false) if state.is_scanning => (spinner, Theme::CYAN), - _ => ('.', Theme::MUTED), - }; - - let name_style = if module.is_done { - Style::default().fg(Theme::TEXT_WHITE) - } else { - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD) - }; - - let name_room = if with_steps { - name_col - } else { - inner_width.saturating_sub(ROW_PREFIX_WIDTH) - }; - - let mut spans = vec![ - Span::styled( - format!(" {} ", marker), - Style::default() - .fg(marker_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{:>3}% ", module.percent), - Style::default().fg(percent_color(module)), - ), - Span::styled( - format!("{} ", module.icon), - Style::default().fg(Theme::CYAN), - ), - Span::styled( - format!( - "{:(module: &ModuleRow<'a>, room: usize) -> Vec> { - let clock = module - .step_elapsed - .filter(|d| *d >= CLOCK_AFTER) - .map(|d| format!(" {}", format_duration(d))) - .unwrap_or_default(); - - let step = if module.step.is_empty() { - "Waiting to start..." - } else { - module.step - }; - - let gap = 2; - let step_room = room - .saturating_sub(gap) - .saturating_sub(clock.chars().count()); - - let step_style = if module.failed { - Style::default().fg(Theme::CORAL) - } else if module.is_done { - Style::default().fg(Theme::MUTED) - } else { - Style::default().fg(Theme::CYAN) - }; - - vec![ - Span::styled(" ".repeat(gap), Style::default()), - Span::styled(truncate(step, step_room), step_style), - // A clock only appears once a step has been running long enough to be - // worth a second look, so it earns the colour that says "look here". - Span::styled(clock, Style::default().fg(Theme::AMBER)), - ] -} - -fn percent_color(module: &ModuleRow) -> ratatui::style::Color { - if module.failed { - Theme::CORAL - } else if module.is_done { - Theme::EMERALD - } else { - Theme::AMBER - } -} - -fn render_log(f: &mut Frame, area: Rect, state: &ScannerViewState) { - let viewport_height = area.height.saturating_sub(2) as usize; - let total_logs = state.log_messages.len(); - - let end_idx = total_logs.saturating_sub(state.log_scroll); - let start_idx = end_idx.saturating_sub(viewport_height); - - let log_lines: Vec = (start_idx..end_idx) - .filter_map(|idx| state.log_messages.get(idx)) - .map(|msg| style_console_line(msg)) - .collect(); - - let title = if state.log_scroll > 0 { - format!( - " LIVE DIAGNOSTIC LOG [lines {}-{} of {} | End = live] ", - start_idx + 1, - end_idx, - total_logs - ) - } else { - format!( - " LIVE DIAGNOSTIC LOG [{}] [PgUp/PgDn to scroll] ", - total_logs - ) - }; - - let log_box = Paragraph::new(log_lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.log_scroll > 0 { - Theme::AMBER - } else { - Theme::BORDER - })) - .title(title), - ); - f.render_widget(log_box, area); -} - -fn render_summary(f: &mut Frame, area: Rect, issues: &[Issue]) { - let count = |severity: Severity| issues.iter().filter(|i| i.severity == severity).count(); - - let summary_line = Line::from(vec![ - Span::styled( - " Scan result: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {} issues in total ", issues.len()), - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" [!] {} critical ", count(Severity::Critical)), - Style::default() - .fg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" [!] {} warnings ", count(Severity::Warning)), - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" [i] {} informational ", count(Severity::Info)), - Style::default().fg(Theme::CYAN), - ), - Span::styled( - " -> Press [3] for issue triage & selection ", - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ), - ]); - - let summary_bar = Paragraph::new(summary_line).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)), - ); - - f.render_widget(summary_bar, area); -} - -/// Cut `text` to `width` columns, marking the cut with an ellipsis. -/// -/// Ratatui would clip an overlong span for us, but silently and at whatever the -/// pane edge happens to be; a visible "..." is the difference between a name -/// that is abbreviated and one that looks corrupted. -fn truncate(text: &str, width: usize) -> String { - if text.chars().count() <= width { - return text.to_string(); - } - if width <= 3 { - return ".".repeat(width); - } - let kept: String = text.chars().take(width - 3).collect(); - format!("{}...", kept.trim_end()) -} - -/// Seconds under a minute, `m:ss` above it. -fn format_duration(d: Duration) -> String { - let secs = d.as_secs(); - if secs < 60 { - format!("{}s", secs) - } else { - format!("{}:{:02}", secs / 60, secs % 60) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - fn row<'a>(name: &'a str, percent: u8, step: &'a str, secs: u64) -> ModuleRow<'a> { - ModuleRow { - name, - icon: "[CLR]", - percent, - is_done: false, - failed: false, - step, - step_elapsed: Some(Duration::from_secs(secs)), - } - } - - /// Renders into the *body* area, which is what `render_app` passes: the - /// header and footer take nine rows off the terminal before this view sees - /// it, so a 30-row terminal is `height = 21` here. - fn screen(state: &ScannerViewState, width: u16, height: u16) -> String { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| render_scanner(f, f.area(), state)) - .unwrap(); - let buffer = terminal.backend().buffer().clone(); - (0..height) - .map(|y| { - (0..width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - fn state<'a>(modules: &'a [ModuleRow<'a>], logs: &'a VecDeque) -> ScannerViewState<'a> { - ScannerViewState { - is_scanning: true, - overall_progress: 30, - modules, - log_messages: logs, - issues: &[], - log_scroll: 0, - elapsed: Some(Duration::from_secs(75)), - } - } - - /// The reported bug: a module parked on a long DISM call showed a bare - /// "10%" and nothing else, so it was indistinguishable from a hang. - #[test] - fn a_running_module_shows_what_it_is_doing_and_for_how_long() { - let logs = VecDeque::new(); - let modules = [row( - "System & Cache Cleaner", - 10, - "Analysing the WinSxS component store (DISM, 1-2 min)...", - 62, - )]; - let rendered = screen(&state(&modules, &logs), 120, 21); - - assert!(rendered.contains("System & Cache Cleaner")); - assert!( - rendered.contains("Analysing the WinSxS component store (DISM, 1-2 min)..."), - "the step, in full, on the module's own row: {rendered}" - ); - assert!( - rendered.contains("1:02"), - "and how long it has been running: {rendered}" - ); - } - - /// The step is the answer to the user's question, so the layout has to keep - /// it whole at the sizes people actually run a terminal at — with all seven - /// modules present, which is what sets the name column width. - /// - /// These two are the longest step labels the modules emit; if a new one - /// outgrows them, this is where that shows up rather than on a user's - /// screen as "Checking the Windows Update services (wuause...". - #[test] - fn the_longest_steps_survive_intact_at_ordinary_terminal_sizes() { - let logs = VecDeque::new(); - let names = [ - "Network & DNS Connectivity", - "Storage & File System", - "System & Cache Cleaner", - "Windows Update & Services", - "Registry & Autostart", - "Event-Log & Crash-Dump Analyse", - "System Integrity (DISM / SFC / VSS)", - ]; - - for step in [ - "Checking the Windows Update services (wuauserv, bits, cryptsvc)...", - "Analysing the WinSxS component store (DISM, 1-2 min)...", - ] { - let modules: Vec = names.iter().map(|n| row(n, 10, step, 62)).collect(); - // Body heights for 30-, 36- and 60-row terminals. - for (w, h) in [(120, 21), (140, 27), (200, 51)] { - let rendered = screen(&state(&modules, &logs), w, h); - assert!( - rendered.contains(step), - "'{step}' was cut short at {w}x{h}:\n{rendered}" - ); - } - } - } - - /// A step that has only just started does not need a clock next to it. - #[test] - fn a_short_step_is_not_cluttered_with_a_timer() { - let logs = VecDeque::new(); - let modules = [row("Network & DNS Connectivity", 20, "Testing DNS...", 1)]; - let rendered = screen(&state(&modules, &logs), 100, 30); - - assert!(rendered.contains("Testing DNS...")); - assert!(!rendered.contains("1s"), "got: {rendered}"); - } - - /// The header has to describe the run, not whichever module spoke last. - #[test] - fn the_gauge_counts_completed_modules_rather_than_naming_one() { - let logs = VecDeque::new(); - let mut modules = [ - row("Network & DNS Connectivity", 100, "Finished", 0), - row("System & Cache Cleaner", 10, "Analysing...", 62), - ]; - modules[0].is_done = true; - - let rendered = screen(&state(&modules, &logs), 100, 30); - assert!(rendered.contains("1/2 modules complete"), "got: {rendered}"); - assert!(rendered.contains("1:15 elapsed"), "got: {rendered}"); - } - - /// A failed module has stopped working and must stop looking busy. - #[test] - fn a_failed_module_is_marked_as_finished_not_as_running() { - let logs = VecDeque::new(); - let mut modules = [row( - "Storage & File System", - 100, - "Failed - access denied", - 0, - )]; - modules[0].is_done = true; - modules[0].failed = true; - - let rendered = screen(&state(&modules, &logs), 100, 30); - assert!( - rendered.contains("Failed - access denied"), - "got: {rendered}" - ); - assert!(rendered.contains(" x "), "the failure marker: {rendered}"); - } - - /// On a terminal too short for the stacked layout the step text is what - /// gives way — every module stays listed, and the log keeps its pane. - #[test] - fn a_short_terminal_keeps_every_module_and_the_log_visible() { - let logs = VecDeque::new(); - let names = [ - "Network & DNS Connectivity", - "Storage & File System", - "System & Cache Cleaner", - "Windows Update & Services", - "Registry & Autostart", - "Event-Log & Crash-Dump Analyse", - "System Integrity (DISM / SFC / VSS)", - ]; - let modules: Vec = names - .iter() - .map(|n| row(n, 10, "Analysing the WinSxS component store...", 30)) - .collect(); - - // The body of an 80x24 terminal, the smallest size the app supports. - let rendered = screen(&state(&modules, &logs), 80, 15); - - // Names are abbreviated hard at this width, so match on enough of each - // to tell them apart rather than on an assumed truncation point. - for name in names { - let identifiable: String = name.chars().take(15).collect(); - assert!( - rendered.contains(&identifiable), - "module '{name}' fell off the pane:\n{rendered}" - ); - } - assert!( - rendered.contains("LIVE DIAGNOSTIC LOG"), - "the log keeps its pane:\n{rendered}" - ); - } - - #[test] - fn durations_read_as_seconds_below_a_minute_and_clock_time_above() { - assert_eq!(format_duration(Duration::from_secs(0)), "0s"); - assert_eq!(format_duration(Duration::from_secs(59)), "59s"); - assert_eq!(format_duration(Duration::from_secs(60)), "1:00"); - assert_eq!(format_duration(Duration::from_secs(125)), "2:05"); - assert_eq!(format_duration(Duration::from_secs(3600)), "60:00"); - } - - #[test] - fn truncation_marks_the_cut_and_never_exceeds_the_width() { - assert_eq!(truncate("short", 20), "short"); - assert_eq!(truncate("System Integrity (DISM)", 12), "System In..."); - assert_eq!(truncate("anything", 2), ".."); - for width in 0..30 { - assert!( - truncate("System Integrity (DISM / SFC / VSS)", width) - .chars() - .count() - <= width - ); - } - } -} diff --git a/src/ui/views/settings.rs b/src/ui/views/settings.rs deleted file mode 100644 index dd27e20..0000000 --- a/src/ui/views/settings.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! Tab 5 — "Settings & Safety". -//! -//! Two lists side by side: what WinMedic is configured to do (left) and what it -//! has already done, plus how to undo it (right). The `[B]` key decides which of -//! the two the arrow keys drive; [`SafetyPanelState::is_focused`] is what tells -//! the user which one that currently is. - -use crate::config::AppConfig; -use crate::ui::theme::Theme; -use crate::ui::widgets::safety_panel::{SafetyPanelState, render_safety_panel}; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; - -pub struct SettingsViewState<'a> { - pub config: &'a AppConfig, - pub selected_setting_index: usize, - pub dry_run: bool, - pub safety: SafetyPanelState<'a>, - /// Where the audit log and the `.reg` snapshots are written. - pub log_dir_path: &'a str, -} - -pub fn render_settings(f: &mut Frame, area: Rect, state: &SettingsViewState) { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(8), // Settings list | Backups & VSS - Constraint::Length(6), // Selected setting - Constraint::Length(5), // Storage locations & rollback hint - ]) - .split(area); - - let top = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) - .split(rows[0]); - - render_setting_list( - f, - top[0], - state.config, - state.selected_setting_index, - !state.safety.is_focused, - ); - render_safety_panel(f, top[1], &state.safety); - // The description used to share this row with a "Recent actions" pane. It - // now spans the full width, which is what keeps a long help text on one line. - render_description( - f, - rows[1], - state.config, - state.selected_setting_index, - state.dry_run, - ); - render_storage_locations(f, rows[2], state.log_dir_path); -} - -/// The configuration list itself. -fn render_setting_list( - f: &mut Frame, - area: Rect, - config: &AppConfig, - selected_index: usize, - is_focused: bool, -) { - let mut items: Vec = Vec::new(); - for idx in 0..AppConfig::SETTING_COUNT { - let Some((label, value, _)) = config.setting_row(idx) else { - continue; - }; - let is_current = idx == selected_index; - - let value_color = match value.as_str() { - "ON" => Theme::EMERALD, - "OFF" => Theme::CORAL, - _ => Theme::CYAN, - }; - - // The cursor is only reverse-videoed while this list owns the arrow - // keys; otherwise it is a dimmer marker that still shows where you were. - let (marker, label_style) = match (is_current, is_focused) { - (true, true) => ( - " > ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - (true, false) => ( - " · ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - _ => (" ", Style::default().fg(Theme::TEXT_WHITE)), - }; - - let line = Line::from(vec![ - Span::styled( - marker, - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(format!("{:<40}", label), label_style), - Span::styled( - format!(" {}", value), - Style::default() - .fg(value_color) - .add_modifier(Modifier::BOLD), - ), - ]); - items.push(ListItem::new(line)); - } - - let border_color = if is_focused { - Theme::CYAN - } else { - Theme::BORDER - }; - let title = if is_focused { - "SETTINGS ◄ [↑/↓]" - } else { - "SETTINGS ([B] to focus)" - }; - - let list = List::new(items) - .block(Theme::card_block(title).border_style(Style::default().fg(border_color))); - f.render_widget(list, area); -} - -/// What the highlighted setting means, and whether repairs are simulated. -fn render_description( - f: &mut Frame, - area: Rect, - config: &AppConfig, - selected_index: usize, - dry_run: bool, -) { - let mut lines = Vec::new(); - if let Some((label, value, help)) = config.setting_row(selected_index) { - lines.push(Line::from(vec![ - Span::styled(" Setting: ", Style::default().fg(Theme::MUTED)), - Span::styled( - label, - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Current: ", Style::default().fg(Theme::MUTED)), - Span::styled( - value, - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - ])); - lines.push(Line::from(vec![Span::styled( - format!(" {}", help), - Style::default().fg(Theme::MUTED), - )])); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Simulation mode [D]: ", Style::default().fg(Theme::MUTED)), - if dry_run { - Span::styled( - "ON - repairs are only shown, never executed", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled( - "OFF - repairs really are executed", - Style::default().fg(Theme::EMERALD), - ) - }, - ])); - - let info_box = Paragraph::new(lines) - .block(Theme::card_block("DESCRIPTION")) - .wrap(Wrap { trim: false }); - f.render_widget(info_box, area); -} - -/// Where settings, logs and backups live on disk, and how to roll back by hand. -fn render_storage_locations(f: &mut Frame, area: Rect, log_dir_path: &str) { - // One path per line: side by side, two full `%APPDATA%` paths overrun even a - // 120-column terminal and the second one gets cut off mid-directory. - let lines = vec![ - Line::from(vec![ - Span::styled( - " Settings file: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - AppConfig::config_path().display().to_string(), - Style::default().fg(Theme::EMERALD), - ), - ]), - Line::from(vec![ - Span::styled( - " Logs & backups: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(log_dir_path, Style::default().fg(Theme::EMERALD)), - ]), - // The footer only advertises [R] while the backup pane holds focus, so - // this line is what keeps the refresh discoverable from either side. - Line::from(vec![ - Span::styled( - " Rollback: ", - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "[B] picks a snapshot, [U] restores it after confirming, [R] reloads. Or run 'reg import '.", - Style::default().fg(Theme::MUTED), - ), - ]), - ]; - - let bottom_box = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)), - ); - - f.render_widget(bottom_box, area); -} diff --git a/src/ui/widgets/confirm_popup.rs b/src/ui/widgets/confirm_popup.rs deleted file mode 100644 index 7087501..0000000 --- a/src/ui/widgets/confirm_popup.rs +++ /dev/null @@ -1,107 +0,0 @@ -use crate::app::ConfirmRequest; -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; - -/// Modal shown before an action that changes the system without a prior scan. -pub fn render_confirm_popup(f: &mut Frame, area: Rect, request: &ConfirmRequest) { - let body = request.body(); - - let is_wide = area.width.saturating_sub(4) >= 100; - let popup_width = if is_wide { - 100.min(area.width.saturating_sub(4)) - } else { - 76.min(area.width.saturating_sub(4)) - }; - let extra_height: u16 = if is_wide { 8 } else { 9 }; - let popup_height = (body.len() as u16 + extra_height).min(area.height.saturating_sub(4)); - let x = (area.width.saturating_sub(popup_width)) / 2; - let y = (area.height.saturating_sub(popup_height)) / 2; - let popup_area = Rect::new(x, y, popup_width, popup_height); - - f.render_widget(Clear, popup_area); - - let mut lines = vec![Line::from("")]; - for text in body { - lines.push(Line::from(vec![Span::styled( - format!(" {}", text), - Style::default().fg(Theme::TEXT_WHITE), - )])); - } - - lines.push(Line::from("")); - if is_wide { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - " [Y] / [Enter] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {} ", request.confirm_label()), - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled( - " [N] / [Esc] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", request.dismiss_label()), - Style::default().fg(Theme::TEXT_WHITE), - ), - ])); - } else { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - " [Y] / [Enter] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", request.confirm_label()), - Style::default().fg(Theme::TEXT_WHITE), - ), - ])); - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - " [N] / [Esc] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", request.dismiss_label()), - Style::default().fg(Theme::TEXT_WHITE), - ), - ])); - } - - let block = Block::default() - .title(format!(" [!] {} ", request.title())) - .title_style( - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::AMBER)); - - let paragraph = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }); - f.render_widget(paragraph, popup_area); -} diff --git a/src/ui/widgets/footer.rs b/src/ui/widgets/footer.rs deleted file mode 100644 index a3ad280..0000000 --- a/src/ui/widgets/footer.rs +++ /dev/null @@ -1,128 +0,0 @@ -use crate::app::{TAB_DASHBOARD, TAB_REPAIR, TAB_SCANNER, TAB_SETTINGS, TAB_TRIAGE}; -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; - -pub fn render_footer( - f: &mut Frame, - area: Rect, - active_tab_index: usize, - status_msg: Option<&str>, - is_busy: bool, - dry_run: bool, - // Which of the two lists on the Settings & Safety tab owns the arrow keys. - backups_focused: bool, -) { - // While something is running, Esc is the key that matters most. - let key_hints: Vec<(&str, &str)> = if is_busy { - vec![ - ("Esc", "Cancel"), - ("←/→", "Tabs"), - ("?", "Help"), - ("Q", "Quit"), - ] - } else { - match active_tab_index { - TAB_DASHBOARD => vec![ - ("←/→", "Tabs"), - ("D", "Simulate"), - ("?", "Help"), - ("Q", "Quit"), - ], - TAB_SCANNER => vec![ - ("←/→", "Tabs"), - ("PgUp/Dn", "Scroll"), - ("R", "Scan"), - ("?", "Help"), - ("Q", "Quit"), - ], - TAB_TRIAGE => vec![ - ("←/→", "Tabs"), - ("↑/↓", "Select"), - ("Space", "On/Off"), - ("C/W/I", "Filter"), - ("/", "Search"), - ("F", "Repair"), - ("E", "Report"), - ("?", "Help"), - ("Q", "Quit"), - ], - TAB_REPAIR => vec![ - ("←/→", "Tabs"), - ("F", "Start"), - ("PgUp/Dn", "Scroll"), - ("D", "Simulate"), - ("E", "Report"), - ("?", "Help"), - ("Q", "Quit"), - ], - // One tab, two lists: the hints follow whichever one has the arrows. - TAB_SETTINGS if backups_focused => vec![ - ("←/→", "Tabs"), - ("↑/↓", "Select Backup"), - ("U", "Rollback"), - ("R", "Refresh VSS"), - ("B/Esc", "Settings"), - ("?", "Help"), - ("Q", "Quit"), - ], - TAB_SETTINGS => vec![ - ("←/→", "Tabs"), - ("↑/↓", "Select"), - ("Space/Enter", "Toggle"), - ("+/-", "Value"), - ("B", "Backups"), - ("?", "Help"), - ("Q", "Quit"), - ], - _ => vec![("←/→", "Tabs"), ("?", "Help"), ("Q", "Quit")], - } - }; - - let mut spans = vec![Span::styled(" ", Style::default())]; - - if dry_run { - spans.push(Span::styled( - " SIMULATION ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled(" ", Style::default())); - } - - for (key, desc) in key_hints { - spans.push(Span::styled( - format!(" [{}] ", key), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!("{} ", desc), - Style::default().fg(Theme::TEXT_WHITE), - )); - } - - if let Some(msg) = status_msg { - spans.push(Span::styled(" │ ", Style::default().fg(Theme::BORDER))); - spans.push(Span::styled( - msg, - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - )); - } - - let footer = Paragraph::new(Line::from(spans)).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)), - ); - - f.render_widget(footer, area); -} diff --git a/src/ui/widgets/header.rs b/src/ui/widgets/header.rs deleted file mode 100644 index c82f9dc..0000000 --- a/src/ui/widgets/header.rs +++ /dev/null @@ -1,161 +0,0 @@ -use crate::ui::theme::Theme; -use crate::utils::hardware::SystemTelemetry; -use ratatui::Frame; -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; - -// Ratatui render functions receive the slice of app state they draw. Passing -// `&App` instead would couple every widget to the whole application struct, so -// the long signature is the deliberate trade-off. -#[allow(clippy::too_many_arguments)] -pub fn render_header( - f: &mut Frame, - area: Rect, - active_tab_index: usize, - telemetry: Option<&SystemTelemetry>, - is_admin: bool, - issue_count: usize, - is_scanning: bool, - dry_run: bool, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(3)]) - .split(area); - - // Top System Bar - let (cpu_str, ram_str, os_str) = if let Some(t) = telemetry { - ( - format!("CPU: {:.1}%", t.cpu_usage), - format!( - "RAM: {:.1}/{:.1}GB", - (t.ram_used_mb as f32 / 1024.0), - (t.ram_total_mb as f32 / 1024.0) - ), - format!("{} {}", t.os_name, t.os_version), - ) - } else { - ( - "CPU: --%".to_string(), - "RAM: --/--GB".to_string(), - "Windows".to_string(), - ) - }; - - let admin_badge = if is_admin { - Span::styled( - " [ADMIN: YES] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled( - " [ADMIN: NO] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - ) - }; - - let mode_badge = if dry_run { - Span::styled( - " [SIMULATION] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled("", Style::default()) - }; - - let top_line = Line::from(vec![ - Span::styled( - format!(" WinMedic v{} ", env!("CARGO_PKG_VERSION")), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled("─ ", Style::default().fg(Theme::BORDER)), - Span::styled( - "Windows Self-Healing Engine ", - Style::default().fg(Theme::MUTED), - ), - mode_badge, - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" {} ", cpu_str), - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" {} ", ram_str), - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled( - format!(" {} ", os_str), - Style::default().fg(Theme::TEXT_WHITE), - ), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - Span::styled(" VSS: Ready ", Style::default().fg(Theme::EMERALD)), - Span::styled("│ ", Style::default().fg(Theme::BORDER)), - admin_badge, - ]); - - let top_bar = Paragraph::new(top_line) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)), - ) - .alignment(Alignment::Left); - - f.render_widget(top_bar, chunks[0]); - - // Navigation Tabs - let scan_indicator = if is_scanning { " (● Scan...)" } else { "" }; - let triage_badge = if issue_count > 0 { - format!(" [{}]", issue_count) - } else { - "".to_string() - }; - - let tab_titles = vec![ - " [1] Dashboard ".to_string(), - format!(" [2] Health Scan{} ", scan_indicator), - format!(" [3] Issue Triage{} ", triage_badge), - " [4] Repair Center ".to_string(), - " [5] Settings & Safety ".to_string(), - ]; - - let tabs = Tabs::new(tab_titles) - .block( - Block::default() - .title(" ◄ [←/→] Tabs ► ") - .title_alignment(Alignment::Right) - .title_style( - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::CYAN)), - ) - .select(active_tab_index) - .style(Style::default().fg(Theme::MUTED)) - .highlight_style( - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ) - .divider("│"); - - f.render_widget(tabs, chunks[1]); -} diff --git a/src/ui/widgets/help_popup.rs b/src/ui/widgets/help_popup.rs deleted file mode 100644 index 828723a..0000000 --- a/src/ui/widgets/help_popup.rs +++ /dev/null @@ -1,162 +0,0 @@ -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; - -/// A section heading followed by its `(key, description)` pairs. -type Section = (&'static str, &'static [(&'static str, &'static str)]); - -const SECTIONS: &[Section] = &[ - ( - "Navigation", - &[ - ("[←]/[→] or [h]/[l]", "Switch tab (BIOS-style navigation)"), - ( - "[1] - [5]", - "Jump directly to tab (Dashboard, Scan, Triage, Repair, Settings & Safety)", - ), - ( - "[Tab] / [Shift+Tab]", - "Cycle forwards / backwards through tabs", - ), - ("[↑]/[↓] or [j]/[k]", "Navigate lists / items"), - ( - "[U]", - "Show available update - download, verify and install it (outside tab 5)", - ), - ("[Esc]", "Back to dashboard"), - ], - ), - ( - "Scan & Repair", - &[ - ("[S] / [R]", "Start or repeat the system health scan"), - ("[F]", "Run the selected repairs"), - ("[D]", "Simulation mode - shows steps, changes nothing"), - ("[PgUp] / [PgDn]", "Scroll the live log up / down"), - ( - "[Home] / [End]", - "Jump to the top of the log / back to the live tail", - ), - ("[E]", "Export the diagnostic report as HTML"), - ("[Esc]", "Cancel a running scan or repair"), - ], - ), - ( - "Issue Triage & Filters (tab 3)", - &[ - ( - "[c] / [w] / [i]", - "Filter by severity (critical / warning / info)", - ), - ("[m]", "Filter by diagnostic module (cycles through)"), - ("[/]", "Search findings and details full-text"), - ("[x]", "Clear all active filters and the search"), - ("[Space]", "Select / deselect an issue for repair"), - ("[A] / [N]", "Select / deselect every visible issue"), - ], - ), - ( - "Settings & Safety (tab 5)", - &[ - ( - "[Enter]", - "Open custom value input dialog (or toggle switch)", - ), - ("[Space]", "Toggle a switch or step numeric value (+Step)"), - ( - "[+] / [-] or [[]/[]]", - "Increase / decrease a numeric value", - ), - ( - "[B]", - "Move [↑]/[↓] between the settings and the backup list", - ), - ("[U]", "Restore the selected .reg backup, after confirming"), - ("[R]", "Reload the restore points and registry backups"), - ], - ), -]; - -pub fn render_help_popup(f: &mut Frame, area: Rect) { - let mut text: Vec = vec![ - Line::from(vec![Span::styled( - format!( - " WinMedic v{} - Keyboard Shortcuts", - env!("CARGO_PKG_VERSION") - ), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - ]; - - for (heading, entries) in SECTIONS { - text.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - format!("{}:", heading), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - ])); - for (key, desc) in *entries { - text.push(Line::from(vec![ - Span::styled( - format!(" {:<26} ", key), - Style::default() - .fg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled(*desc, Style::default().fg(Theme::TEXT_WHITE)), - ])); - } - text.push(Line::from("")); - } - - text.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - "Safety:", - Style::default() - .fg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " VSS restore points, registry backups and the rollback all live in tab [5].", - Style::default().fg(Theme::MUTED), - ), - ])); - text.push(Line::from("")); - text.push(Line::from(vec![Span::styled( - " Press [?] or [Esc] to close this help.", - Style::default() - .fg(Theme::MUTED) - .add_modifier(Modifier::ITALIC), - )])); - - let popup_width = 88.min(area.width.saturating_sub(4)); - let popup_height = (text.len() as u16 + 2).min(area.height.saturating_sub(2)); - let x = (area.width.saturating_sub(popup_width)) / 2; - let y = (area.height.saturating_sub(popup_height)) / 2; - let popup_area = Rect::new(x, y, popup_width, popup_height); - - f.render_widget(Clear, popup_area); - - let block = Block::default() - .title(" HELP & KEYBOARD SHORTCUTS ") - .title_style( - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::CYAN)); - - let paragraph = Paragraph::new(text).block(block); - f.render_widget(paragraph, popup_area); -} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs deleted file mode 100644 index a788dbb..0000000 --- a/src/ui/widgets/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod confirm_popup; -pub mod footer; -pub mod header; -pub mod help_popup; -pub mod safety_panel; -pub mod setting_input_popup; diff --git a/src/ui/widgets/safety_panel.rs b/src/ui/widgets/safety_panel.rs deleted file mode 100644 index 996d3f9..0000000 --- a/src/ui/widgets/safety_panel.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! The safety surface: VSS restore points and registry snapshots. -//! -//! These panes used to be a tab of their own ("Backups & Logs"). They are -//! widgets rather than a view because the Settings & Safety tab composes them -//! next to the configuration list, and the dashboard renders its own one-line -//! summary of the audit trail from [`latest_action_line`]. - -use crate::safety::audit::AuditEntry; -use crate::safety::reg_backup::BackupRecord; -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Wrap}; - -pub struct SafetyPanelState<'a> { - /// Backup records, newest first — the order shown and selected against. - pub backup_records: &'a [&'a BackupRecord], - pub selected_backup_index: usize, - pub vss_restore_points: &'a [String], - pub restore_points_loading: bool, - pub is_restoring: bool, - /// True while `↑`/`↓` drive the backup list rather than the settings list. - pub is_focused: bool, -} - -/// How many backup records fit in the pane before it starts scrolling. -const VISIBLE_BACKUPS: usize = 5; - -/// How many VSS restore points to list before collapsing the rest into a count. -const VISIBLE_RESTORE_POINTS: usize = 2; - -/// Restore points and registry snapshots, with the `[U]` rollback target marked. -pub fn render_safety_panel(f: &mut Frame, area: Rect, state: &SafetyPanelState) { - let mut lines = vec![Line::from(vec![Span::styled( - " System restore points (VSS):", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )])]; - - if state.restore_points_loading { - lines.push(Line::from(vec![Span::styled( - " Querying Windows restore points...", - Style::default().fg(Theme::AMBER), - )])); - } else if state.vss_restore_points.is_empty() { - lines.push(Line::from(vec![Span::styled( - " (No restore points found - [R] refreshes)", - Style::default().fg(Theme::MUTED), - )])); - } else { - // Deliberately short. Restore points are read-only context; the - // snapshot list below is the part the user can act on, and on an 80 - // column terminal a long VSS list pushes it off the pane entirely. - for rp in state.vss_restore_points.iter().take(VISIBLE_RESTORE_POINTS) { - lines.push(Line::from(vec![ - Span::styled(" [OK] ", Style::default().fg(Theme::EMERALD)), - Span::styled(rp.as_str(), Style::default().fg(Theme::TEXT_WHITE)), - ])); - } - if let Some(hidden) = state - .vss_restore_points - .len() - .checked_sub(VISIBLE_RESTORE_POINTS) - .filter(|n| *n > 0) - { - lines.push(Line::from(vec![Span::styled( - format!(" + {} more restore point(s)", hidden), - Style::default().fg(Theme::MUTED), - )])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - " Saved registry snapshots (.reg):", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )])); - - if state.backup_records.is_empty() { - lines.push(Line::from(vec![Span::styled( - " (No backed-up registry modifications yet)", - Style::default().fg(Theme::MUTED), - )])); - } else { - // Only a handful of entries fit; scroll the window so the selected - // backup stays visible even with a long backup history. - let offset = state - .selected_backup_index - .saturating_sub(VISIBLE_BACKUPS - 1) - .min(state.backup_records.len().saturating_sub(VISIBLE_BACKUPS)); - - if offset > 0 { - lines.push(Line::from(vec![Span::styled( - format!(" ↑ {} newer backup(s)", offset), - Style::default().fg(Theme::MUTED), - )])); - } - - for (idx, rec) in state - .backup_records - .iter() - .enumerate() - .skip(offset) - .take(VISIBLE_BACKUPS) - { - // The cursor is only highlighted while this pane owns the arrow - // keys. A reverse-video row on an unfocused list reads as "this is - // what [U] will restore right now", which is only true after [B]. - let is_current = idx == state.selected_backup_index; - let (marker, title_style) = match (is_current, state.is_focused) { - (true, true) => ( - " > ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - (true, false) => ( - " · ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - _ => (" ", Style::default().fg(Theme::TEXT_WHITE)), - }; - - lines.push(Line::from(vec![ - Span::styled(marker, Style::default().fg(Theme::CYAN)), - Span::styled("[REG] ", Style::default().fg(Theme::AMBER)), - Span::styled( - format!("[{}] ", rec.timestamp), - Style::default().fg(Theme::MUTED), - ), - Span::styled(rec.description.clone(), title_style), - ])); - lines.push(Line::from(vec![Span::styled( - format!(" {}", rec.key_path), - Style::default().fg(Theme::MUTED), - )])); - } - } - - // The full key list lives in the footer; the title only has to say which - // pane the arrows are pointed at. Spelling out [U]/[R] here too made the - // title longer than the pane at 120 columns, so it clipped mid-word. - let title = if state.is_restoring { - "BACKUPS & VSS - ROLLBACK RUNNING..." - } else if state.is_focused { - "BACKUPS & VSS ◄ [↑/↓]" - } else { - "BACKUPS & VSS ([B] to focus)" - }; - - // A cyan border marks which of the tab's two lists the arrow keys drive. - let border_color = if state.is_focused { - Theme::CYAN - } else { - Theme::BORDER - }; - - let panel = Paragraph::new(lines) - .block(Theme::card_block(title).border_style(Style::default().fg(border_color))) - // `trim: false` keeps the two-space indent that groups a snapshot's key - // path under its description; trimming flattens the whole pane. - .wrap(Wrap { trim: false }); - - f.render_widget(panel, area); -} - -/// One audit entry as `[time] [ACTION] [STATUS] title`. -fn audit_line(entry: &AuditEntry) -> Line<'static> { - let (status_icon, status_color) = match entry.status.as_str() { - "SUCCESS" => ("[OK]", Theme::EMERALD), - "FAILED" => ("[X]", Theme::CORAL), - "WARNING" => ("[WARN]", Theme::AMBER), - _ => ("[INFO]", Theme::CYAN), - }; - - Line::from(vec![ - Span::styled( - format!("[{}] ", entry.timestamp), - Style::default().fg(Theme::MUTED), - ), - Span::styled( - format!("[{}] ", entry.action_type), - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{} ", status_icon), - Style::default() - .fg(status_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled(entry.title.clone(), Style::default().fg(Theme::TEXT_WHITE)), - ]) -} - -/// A single-line summary of the newest audit entry, for the dashboard. -/// -/// Returns `None` when nothing has been logged yet, so the caller can decide -/// whether an empty row is worth the vertical space. -pub fn latest_action_line(entries: &[AuditEntry]) -> Option> { - let entry = entries.last()?; - let mut spans = vec![Span::styled( - " Last action: ", - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - )]; - spans.extend(audit_line(entry).spans); - spans.push(Span::styled( - " │ [5] Full log, backups & rollback", - Style::default().fg(Theme::MUTED), - )); - Some(Line::from(spans)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(status: &str, title: &str) -> AuditEntry { - AuditEntry { - timestamp: "2026-01-01 12:00:00".to_string(), - action_type: "REPAIR".to_string(), - module_id: "registry_startup".to_string(), - title: title.to_string(), - status: status.to_string(), - details: String::new(), - } - } - - #[test] - fn latest_action_line_reports_the_newest_entry() { - let entries = vec![entry("SUCCESS", "older"), entry("FAILED", "newest")]; - - let line = latest_action_line(&entries).expect("an entry exists"); - let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); - - assert!(rendered.contains("newest"), "got: {rendered}"); - assert!(!rendered.contains("older"), "got: {rendered}"); - assert!( - rendered.contains("[X]"), - "failures stay visible: {rendered}" - ); - } - - #[test] - fn latest_action_line_is_absent_before_anything_is_logged() { - assert!(latest_action_line(&[]).is_none()); - } -} diff --git a/src/ui/widgets/setting_input_popup.rs b/src/ui/widgets/setting_input_popup.rs deleted file mode 100644 index 34baffb..0000000 --- a/src/ui/widgets/setting_input_popup.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crate::app::state::SettingInput; -use crate::ui::theme::Theme; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; - -/// Modal shown when editing a numeric setting threshold via direct input. -pub fn render_setting_input_popup(f: &mut Frame, area: Rect, input: &SettingInput) { - let popup_width = 68.min(area.width.saturating_sub(4)); - let popup_height = 11.min(area.height.saturating_sub(4)); - let x = (area.width.saturating_sub(popup_width)) / 2; - let y = (area.height.saturating_sub(popup_height)) / 2; - let popup_area = Rect::new(x, y, popup_width, popup_height); - - f.render_widget(Clear, popup_area); - - let display_buffer = format!("{}_", input.buffer); - - let mut lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Setting: ", Style::default().fg(Theme::MUTED)), - Span::styled( - &input.setting_name, - Style::default() - .fg(Theme::TEXT_WHITE) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::styled(" Unit: ", Style::default().fg(Theme::MUTED)), - Span::styled( - format!( - "{} (Range: {} - {})", - input.unit, input.min_value, input.max_value - ), - Style::default().fg(Theme::CYAN), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" Value: [ ", Style::default().fg(Theme::MUTED)), - Span::styled( - format!("{:<16}", display_buffer), - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" ]", Style::default().fg(Theme::MUTED)), - ]), - ]; - - if let Some(err) = &input.error_msg { - lines.push(Line::from(vec![ - Span::styled( - " [!] ", - Style::default() - .fg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - ), - Span::styled(err, Style::default().fg(Theme::CORAL)), - ])); - } else { - lines.push(Line::from("")); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - " [Enter] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::EMERALD) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Save ", Style::default().fg(Theme::TEXT_WHITE)), - Span::styled( - " [Esc] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::CORAL) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Cancel ", Style::default().fg(Theme::TEXT_WHITE)), - Span::styled( - " [Bksp] ", - Style::default() - .fg(Theme::BG_DEEP) - .bg(Theme::AMBER) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Delete", Style::default().fg(Theme::TEXT_WHITE)), - ])); - - let block = Block::default() - .title(" EDIT SETTING VALUE ") - .title_style( - Style::default() - .fg(Theme::CYAN) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::CYAN)); - - let paragraph = Paragraph::new(lines).block(block); - f.render_widget(paragraph, popup_area); -} diff --git a/src/utils/console.rs b/src/utils/console.rs new file mode 100644 index 0000000..77201b5 --- /dev/null +++ b/src/utils/console.rs @@ -0,0 +1,55 @@ +//! Handing the console back when WinMedic starts as a desktop application. +//! +//! WinMedic is a single binary linked into the *console* subsystem, and that is +//! deliberate. The alternative — `#![windows_subsystem = "windows"]` — buys a +//! GUI that never flashes a console window, and pays for it by breaking every +//! headless guarantee the CLI help makes: a Windows-subsystem process does not +//! hold the shell, so `winmedic --json > report.json` returns before the file +//! is finished and the documented exit codes arrive after whatever ran next. +//! The release workflow depends on it too, capturing `winmedic.exe --version` +//! into a variable it compares against the tag. +//! +//! So the console stays and the GUI gives it back on the way up. The cost is a +//! console window visible for as long as it takes to reach `main`: a flicker +//! when WinMedic is launched from the desktop, and nothing at all when it is +//! launched from a shell — because then it was never ours to give back. + +/// Release the console, but only when this process is the one that owns it. +/// +/// Ownership is the whole question. A process started from Explorer gets a +/// console to itself, so closing it removes a window the user never asked for. +/// A process started from `cmd.exe`, PowerShell or Windows Terminal *shares* +/// the shell's console — freeing that one detaches WinMedic from the terminal +/// it was typed into and leaves the user at a prompt that prints nothing. +/// +/// `GetConsoleProcessList` answers it directly: it reports how many processes +/// are attached to this console, and one means us and nobody else. +/// +/// Returns whether the console was actually released. +#[cfg(windows)] +pub fn release_console_if_owned() -> bool { + use windows_sys::Win32::Foundation::FALSE; + use windows_sys::Win32::System::Console::{FreeConsole, GetConsoleProcessList}; + + // Two slots are enough to tell "exactly one" from "more than one": the call + // reports the number of attached processes even when the buffer is too + // small to list them all, and their identities are of no interest here. + let mut attached = [0u32; 2]; + + // SAFETY: the pointer and the length describe the same live array, which is + // what the call contracts for. It writes at most `len` process ids and + // returns the count — zero when there is no console attached at all. + let count = unsafe { GetConsoleProcessList(attached.as_mut_ptr(), attached.len() as u32) }; + + if count != 1 { + return false; + } + + // SAFETY: takes no arguments, and this process is attached to a console. + unsafe { FreeConsole() != FALSE } +} + +#[cfg(not(windows))] +pub fn release_console_if_owned() -> bool { + false +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 508f529..d931a13 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod admin; pub mod cmd; +pub mod console; pub mod debug_log; pub mod fs_stats; pub mod hardware; diff --git a/src/utils/self_update.rs b/src/utils/self_update.rs index deb7d8d..de80aca 100644 --- a/src/utils/self_update.rs +++ b/src/utils/self_update.rs @@ -645,7 +645,7 @@ pub fn clean_leftovers_beside_current_exe() -> usize { /// Same shape and same reason as /// [`crate::safety::restore_point::RestorePointService`]: [`Self::inert`] is the /// default, so an [`crate::app::App`] built in a test cannot download or install -/// anything, and only the TUI entry point installs the real one. +/// anything, and only the desktop front end installs the real one. pub type InstallFuture = Pin> + Send>>; diff --git a/tests/challenger_m2_adversarial.rs b/tests/integration/challenger_m2_adversarial.rs similarity index 99% rename from tests/challenger_m2_adversarial.rs rename to tests/integration/challenger_m2_adversarial.rs index 4fa48ce..0c9190f 100644 --- a/tests/challenger_m2_adversarial.rs +++ b/tests/integration/challenger_m2_adversarial.rs @@ -558,8 +558,8 @@ async fn adv_test_diagnostic_engine_full_integration_with_system_cleaner() { let engine = DiagnosticEngine::with_runner(&config, Arc::new(mock.clone())); assert_eq!( engine.modules().len(), - 9, - "DiagnosticEngine must register exactly 9 modules" + 11, + "DiagnosticEngine must register exactly 11 modules" ); let (tx, mut rx) = mpsc::channel::(100); diff --git a/tests/m3_adversarial_challenger_test.rs b/tests/integration/m3_adversarial_challenger_test.rs similarity index 100% rename from tests/m3_adversarial_challenger_test.rs rename to tests/integration/m3_adversarial_challenger_test.rs diff --git a/tests/test_milestone3_challenger2.rs b/tests/integration/test_milestone3_challenger2.rs similarity index 100% rename from tests/test_milestone3_challenger2.rs rename to tests/integration/test_milestone3_challenger2.rs diff --git a/tests/tier1_features.rs b/tests/integration/tier1_features.rs similarity index 91% rename from tests/tier1_features.rs rename to tests/integration/tier1_features.rs index d1c70ce..44c0842 100644 --- a/tests/tier1_features.rs +++ b/tests/integration/tier1_features.rs @@ -1,6 +1,6 @@ #![allow(dead_code, unused_imports)] -mod common; +use crate::common; use std::fs::File; use std::io::Write; @@ -861,6 +861,7 @@ fn test_tier1_f11_recursive_directory_size_sum() { #[tokio::test] async fn test_tier1_f11_triage_issue_toggle_in_app() { let mut app = App::new(); + app.issues.clear(); let issue = winmedic::engine::issue::Issue::new( "sys_clean_browser_cache", "system_cleaner", @@ -887,6 +888,7 @@ async fn test_tier1_f11_triage_issue_toggle_in_app() { #[tokio::test] async fn test_tier1_f11_triage_select_and_deselect_all() { let mut app = App::new(); + app.issues.clear(); for i in 0..5 { app.issues.push(winmedic::engine::issue::Issue::new( format!("issue_{}", i), @@ -916,6 +918,7 @@ async fn test_tier1_f11_triage_select_and_deselect_all() { #[tokio::test] async fn test_tier1_f11_triage_navigation_bounds() { let mut app = App::new(); + app.issues.clear(); for i in 0..3 { app.issues.push(winmedic::engine::issue::Issue::new( format!("issue_{}", i), @@ -947,11 +950,11 @@ async fn test_tier1_f11_triage_navigation_bounds() { // ============================================================================ #[test] -fn test_tier1_f12_all_modules_count_equals_nine() { +fn test_tier1_f12_all_modules_count_equals_eleven() { let cfg = ModuleConfig::default(); let runner = Arc::new(ProgrammableMockRunner::new()); let modules = get_all_modules_with_runner(&cfg, runner); - assert_eq!(modules.len(), 9); + assert_eq!(modules.len(), 11); } #[test] @@ -982,6 +985,20 @@ fn test_tier1_f12_page_file_metadata() { assert_eq!(page_file.icon(), "[MEM]"); } +#[test] +fn test_tier1_f12_whea_logger_metadata() { + let cfg = ModuleConfig::default(); + let runner = Arc::new(ProgrammableMockRunner::new()); + let modules = get_all_modules_with_runner(&cfg, runner); + + let whea = modules + .iter() + .find(|m| m.id() == "whea_logger") + .expect("the whea_logger module must be registered"); + assert_eq!(whea.name(), "WHEA Hardware Error Logger"); + assert_eq!(whea.icon(), "[WHEA]"); +} + #[test] fn test_tier1_f12_system_cleaner_metadata() { let cfg = ModuleConfig::default(); @@ -996,21 +1013,26 @@ fn test_tier1_f12_system_cleaner_metadata() { } #[test] -fn test_tier1_f12_diagnostic_engine_contains_all_nine_modules() { +fn test_tier1_f12_diagnostic_engine_contains_all_eleven_modules() { let config = AppConfig::default(); let engine = DiagnosticEngine::new(&config); - assert_eq!(engine.modules().len(), 9); + assert_eq!(engine.modules().len(), 11); } #[tokio::test] -async fn test_tier1_f12_app_initializes_with_nine_module_statuses() { +async fn test_tier1_f12_app_initializes_with_eleven_module_statuses() { let app = App::new(); - assert_eq!(app.module_statuses.len(), 9); + assert_eq!(app.module_statuses.len(), 11); assert!( app.module_statuses .iter() .any(|(id, ..)| id == "system_cleaner") ); + assert!( + app.module_statuses + .iter() + .any(|(id, ..)| id == "whea_logger") + ); } #[test] @@ -1023,6 +1045,101 @@ fn test_tier1_f12_all_module_ids_unique() { } } +#[tokio::test] +async fn test_tier1_f12_whea_logger_scan_and_repair_integration() { + use winmedic::engine::runner::{DiagnosticEngine, RepairOptions}; + + let runner = Arc::new(ProgrammableMockRunner::new()); + runner.set_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: Microsoft-Windows-WHEA-Logger + Event ID: 17 + Level: Warning + Component: PCI Express Root Port + Primary Bus:Device:Function: 0x0:0x1:0x1 + Primary Device Name: PCI\VEN_1022&DEV_1453"#, + ), + ); + runner.set_response("powercfg.exe", CmdOutput::ok("")); + + let cfg = ModuleConfig::default(); + let module = winmedic::modules::whea_logger::WheaLoggerModule::with_runner(cfg, runner.clone()); + + let issues = module.scan(None).await.expect("scan failed"); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].id, "whea_pcie_bus_error"); + assert_eq!(issues[0].category, "Hardware & Stability"); + + let fix_res = module.fix("whea_pcie_bus_error", None).await; + assert!(fix_res.is_ok()); + assert!( + fix_res + .unwrap() + .contains("PCIe Link State Power Management") + ); + + let powercfg_calls = runner.calls_for("powercfg.exe"); + assert!(!powercfg_calls.is_empty()); +} + +#[test] +fn test_tier1_f12_crash_analysis_metadata() { + let cfg = ModuleConfig::default(); + let runner = Arc::new(ProgrammableMockRunner::new()); + let modules = get_all_modules_with_runner(&cfg, runner); + + let crash = modules + .iter() + .find(|m| m.id() == "crash_analysis") + .expect("the crash_analysis module must be registered"); + assert_eq!(crash.name(), "Crash Dump & BSOD Analyzer"); + assert_eq!(crash.icon(), "[DMP]"); +} + +#[tokio::test] +async fn test_tier1_f12_crash_analysis_scan_and_repair_integration() { + let runner = Arc::new(ProgrammableMockRunner::new()); + runner.set_response( + "wevtutil.exe", + CmdOutput::ok( + r#"Event[0]: + Source: BugCheck + Event ID: 1001 + Description: +The computer has rebooted from a bugcheck. The bugcheck was: 0x00000116 (0xffffc8073e4a3010, 0xfffff8024a123456)."#, + ), + ); + runner.set_response("cmd.exe", CmdOutput::ok("")); + + let dump_dir = std::env::temp_dir().join(format!("winmedic_dmp_it_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dump_dir); + std::fs::create_dir_all(&dump_dir).expect("temp dump dir"); + + let cfg = ModuleConfig::default(); + let module = winmedic::modules::crash_analysis::CrashAnalysisModule::with_runner_and_dump_dir( + cfg, + runner.clone(), + &dump_dir, + ); + + let issues = module.scan(None).await.expect("scan failed"); + let tdr = issues + .iter() + .find(|i| i.id == "crash_video_tdr") + .expect("video TDR issue from Event 1001"); + assert_eq!(tdr.category, "Hardware & Stability"); + + let fix_res = module.fix("crash_video_tdr", None).await; + assert!(fix_res.is_ok()); + assert!(fix_res.unwrap().contains("Device Manager")); + + assert!(!runner.calls_for("cmd.exe").is_empty()); + + let _ = std::fs::remove_dir_all(&dump_dir); +} + // ============================================================================ // FEATURE 13: GitHub Release Version Check (M3, R3.1) // ============================================================================ diff --git a/tests/tier2_boundaries.rs b/tests/integration/tier2_boundaries.rs similarity index 99% rename from tests/tier2_boundaries.rs rename to tests/integration/tier2_boundaries.rs index 18a6a41..de2e615 100644 --- a/tests/tier2_boundaries.rs +++ b/tests/integration/tier2_boundaries.rs @@ -1,6 +1,6 @@ #![allow(dead_code, unused_imports)] -mod common; +use crate::common; use std::fs::{self, File}; use std::io::Write; @@ -825,6 +825,7 @@ fn test_tier2_f11_format_bytes_boundary_exact_values() { #[tokio::test] async fn test_tier2_f11_triage_empty_search_query_matches_all() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "i1", "mod1", @@ -858,6 +859,7 @@ async fn test_tier2_f11_triage_empty_search_query_matches_all() { #[tokio::test] async fn test_tier2_f11_triage_search_query_no_match_clamps_to_zero() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "i1", "mod1", @@ -882,6 +884,7 @@ async fn test_tier2_f11_triage_search_query_no_match_clamps_to_zero() { #[tokio::test] async fn test_tier2_f11_triage_filter_all_severities_combinations() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "i1", "m", @@ -932,6 +935,7 @@ async fn test_tier2_f11_triage_filter_all_severities_combinations() { #[tokio::test] async fn test_tier2_f11_triage_clear_filters_restores_view() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "i1", "m", diff --git a/tests/tier3_combinations.rs b/tests/integration/tier3_combinations.rs similarity index 99% rename from tests/tier3_combinations.rs rename to tests/integration/tier3_combinations.rs index 18b6084..db7b7e7 100644 --- a/tests/tier3_combinations.rs +++ b/tests/integration/tier3_combinations.rs @@ -1,6 +1,6 @@ #![allow(dead_code, unused_imports)] -mod common; +use crate::common; use std::fs::{self, File}; use std::io::Write; @@ -222,7 +222,7 @@ async fn test_tier3_multi_module_scan_parallelism() { } let _issues = scan_handle.await.unwrap(); - assert_eq!(finished_modules, 9); + assert_eq!(finished_modules, 11); } /// `create_vss: true` used to run `Checkpoint-Computer` on the machine running @@ -470,6 +470,7 @@ fn test_tier3_audit_logger_records_system_cleaner_fixes() { #[tokio::test] async fn test_tier3_triage_search_filtering_by_system_cleaner_title() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "sys_clean_winsxs", "system_cleaner", @@ -504,6 +505,7 @@ async fn test_tier3_triage_search_filtering_by_system_cleaner_title() { #[tokio::test] async fn test_tier3_triage_module_filtering_cycle() { let mut app = App::new(); + app.issues.clear(); app.issues.push(Issue::new( "sys_clean_winsxs", "system_cleaner", diff --git a/tests/tier4_scenarios.rs b/tests/integration/tier4_scenarios.rs similarity index 99% rename from tests/tier4_scenarios.rs rename to tests/integration/tier4_scenarios.rs index 3f57d54..80cb98e 100644 --- a/tests/tier4_scenarios.rs +++ b/tests/integration/tier4_scenarios.rs @@ -1,6 +1,6 @@ #![allow(dead_code, unused_imports)] -mod common; +use crate::common; use std::fs::{self, File}; use std::io::Write; @@ -80,7 +80,7 @@ async fn test_scenario_1_full_system_scan_and_dry_run_triage() { } let mut issues = scan_handle.await.unwrap(); - assert_eq!(finished_modules, 9); + assert_eq!(finished_modules, 11); assert!(!issues.is_empty()); // 2. Triage state: verify issues present and select all diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..968e7f4 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,22 @@ +mod common; + +#[path = "integration/tier1_features.rs"] +mod tier1_features; + +#[path = "integration/tier2_boundaries.rs"] +mod tier2_boundaries; + +#[path = "integration/tier3_combinations.rs"] +mod tier3_combinations; + +#[path = "integration/tier4_scenarios.rs"] +mod tier4_scenarios; + +#[path = "integration/challenger_m2_adversarial.rs"] +mod challenger_m2_adversarial; + +#[path = "integration/m3_adversarial_challenger_test.rs"] +mod m3_adversarial_challenger_test; + +#[path = "integration/test_milestone3_challenger2.rs"] +mod test_milestone3_challenger2;