diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a1d9ba..66ced103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,13 @@ jobs: - name: Build (release) run: cargo build --release + # The portable artifact is only built by release.yml, so without this it + # would break silently between tags and only be discovered while cutting + # a release. Windows-only: the embedded sidecar is a no-op elsewhere. + - name: Build the portable exe (Windows) + if: matrix.platform == 'windows-latest' + run: cargo build --release --features portable --target-dir target-portable + - name: Install cargo-packager run: cargo install cargo-packager --version 0.11.8 --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cb55520..8aca4e1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -230,6 +230,62 @@ jobs: - name: Package installers run: cargo packager --release --formats ${{ matrix.formats }} --verbose + # The portable artifact: one .exe that runs from a USB stick or a network + # share with nothing beside it. + # + # `--features portable` compiles the LibreHardwareMonitor sidecar into the + # binary, which is what makes that true — every Windows sensor comes from + # that sidecar, so a bare sensorview.exe would open reporting nothing at + # all. It costs ~70 MB, which is why only this artifact pays for it. + # + # Built into its own target directory so it cannot overwrite the binary + # cargo-packager has just wrapped into the installer, then copied into + # target/release/ under its final name for the upload glob below. + - name: Build the portable exe (Windows) + if: matrix.platform == 'windows-latest' + shell: bash + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cargo build --release --features portable --target-dir target-portable + out="target/release/SensorView-${VERSION}-portable.exe" + cp target-portable/release/sensorview.exe "$out" + ls -lh "$out" + + # Names the artifacts so the release page is readable, and — more to the + # point — proves each one exists. Publishing silently attaches whatever + # the globs happen to match, so a format that failed to build would + # otherwise show up as a release that is simply missing a platform. + - name: List and check the artifacts + shell: bash + run: | + set -euo pipefail + echo "::group::Artifacts built on ${{ matrix.platform }}" + ls -lh target/release/*-setup.exe target/release/*-portable.exe \ + target/release/*.deb target/release/*.AppImage \ + target/release/*.dmg 2>/dev/null || true + echo "::endgroup::" + + missing="" + case "${{ matrix.platform }}" in + windows-latest) + compgen -G "target/release/*-setup.exe" >/dev/null || missing="$missing nsis-setup.exe" + compgen -G "target/release/*-portable.exe" >/dev/null || missing="$missing portable.exe" + ;; + ubuntu-22.04) + compgen -G "target/release/*.deb" >/dev/null || missing="$missing .deb" + compgen -G "target/release/*.AppImage" >/dev/null || missing="$missing .AppImage" + ;; + macos-latest) + compgen -G "target/release/*.dmg" >/dev/null || missing="$missing .dmg" + ;; + esac + if [ -n "$missing" ]; then + echo "::error::${{ matrix.platform }} produced no:$missing" + exit 1 + fi + # tag_name is explicit: on a manual run the workflow's own ref is a # branch, so the action would otherwise attach the release to the wrong # thing. All three legs target the same release and the action merges @@ -243,16 +299,28 @@ jobs: body: | SensorView ${{ needs.prepare.outputs.version }} - Download the installer for your platform below. + Download the build for your platform below. + + | Platform | File | | + |---|---|---| + | Windows | `SensorView_*_x64-setup.exe` | Installer | + | Windows | `SensorView-*-portable.exe` | Single file, no install — see below | + | macOS (Apple Silicon) | `*.dmg` | Unsigned; System Settings → Privacy & Security → Open Anyway | + | Linux | `*.deb` / `*.AppImage` | | + + **The portable Windows build** is one self-contained `.exe`. It + carries the LibreHardwareMonitor sensor bridge inside it and unpacks + it to `%LOCALAPPDATA%\SensorView\bridge\` on first run, so it works + from a USB stick with nothing beside it. That is why it is much + larger than the installer. - | Platform | File | - |---|---| - | Windows | `*-setup.exe` | - | macOS (Apple Silicon) | `*.dmg` — unsigned; System Settings → Privacy & Security → Open Anyway | - | Linux | `*.deb` / `*.AppImage` | + Windows asks for administrator rights on launch: Super-I/O, MSR and + SMBus sensors are unreadable without them. Declining still gets you + the process list and the sensors that do not need a driver. # Also relative to app/ via the job default. files: | target/release/*-setup.exe + target/release/*-portable.exe target/release/*.deb target/release/*.AppImage target/release/*.dmg diff --git a/README.md b/README.md index abc66f81..9fc84d17 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ sensorview report # the GUI's text report, from the CLI # Processes — the Task Manager's data, without the window sensorview ps # busiest first -sensorview ps --sort mem --limit 10 # sort by cpu | mem | pid | name +sensorview ps --sort mem --limit 10 # sort by cpu | mem | disk | pid | name sensorview ps --filter chrome --json # pipe into jq sensorview kill 4711 # SIGTERM; --force sends SIGKILL @@ -245,10 +245,18 @@ Download the installer for your platform from [Releases](https://github.com/Zekt | Platform | Artifact | Notes | |---|---|---| -| Windows | `SensorView-setup.exe` (NSIS) | Bundles the sensor sidecar; optionally installs the PawnIO driver | +| Windows | `SensorView__x64-setup.exe` (NSIS) | Bundles the sensor sidecar; optionally installs the PawnIO driver | +| Windows | `SensorView--portable.exe` | One file, no install — see below | | macOS | `SensorView__aarch64.dmg` | Apple Silicon only; **unsigned** — see below | | Linux | `.deb` / `.AppImage` | | +**The portable Windows build** is a single self-contained `.exe`. Every Windows +sensor comes from the LibreHardwareMonitor sidecar, so the portable build +carries that sidecar inside the binary and unpacks it to +`%LOCALAPPDATA%\SensorView\bridge\` the first time it runs — which is why it is +several times the size of the installer, and why it works from a USB stick with +nothing beside it. Delete that folder to reclaim the space once you are done. + **macOS first launch:** the `.dmg` is currently unsigned and un-notarized, so Gatekeeper will block it. Open **System Settings → Privacy & Security**, find the blocked-app notice, and choose **Open Anyway**. diff --git a/TODO.md b/TODO.md index 3e046d47..7ecce74a 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,7 @@ What remains open: Found by running the **release** binaries on Windows 11 for the first time (2026-07-31), which is also what retired the old "Windows CLI console handling is untested" entry. Three reproducible defects, plus the reason CI missed all of -them. None are fixed yet. +them. The elevation one is now fixed; the other two are still open. What *does* work, so it is not re-investigated: `AttachConsole` itself (a GUI-subsystem binary's subcommand output does reach a real console), the @@ -63,32 +63,28 @@ Fix: on Windows either fall back to `Signal::Kill` with the wording changed to match, or disable the non-force action rather than offering something the platform cannot do. -### Every release build demands elevation, including the headless one +### ~~Every release build demands elevation, including the headless one~~ — fixed -`build.rs` gates the `requireAdministrator` manifest on `PROFILE == "release"` -alone, not on the `gui` feature. So the headless binary — the one the README -offers "for servers and containers" — also requires admin. A non-elevated -parent cannot start it at all: +`build.rs` gated the `requireAdministrator` manifest on `PROFILE == "release"` +alone, not on the `gui` feature, so the headless binary — the one the README +offers "for servers and containers" — also demanded admin. A non-elevated +parent could not start it at all: `CreateProcess` failed with +`ERROR_ELEVATION_REQUIRED` (740), and with `UseShellExecute = false` there is no +UAC prompt to accept, so a service, scheduled task, container entrypoint or CI +step running as a normal user failed outright. -``` -CreateProcess FAILED: The requested operation requires elevation (error 740) -``` +Now gated on the `gui` feature as well as the profile. Verified by PE header +and by running the headless release binary unelevated, which previously could +not start: + +| build | subsystem | manifest | +|---|---|---| +| release, `gui` | GUI | `requireAdministrator` | +| release, headless | console | `asInvoker` | -With `UseShellExecute = false` there is no UAC prompt to accept: the process -simply never starts, so a service, scheduled task, container entrypoint or CI -step running as a normal user fails outright — and with it every documented -scripting use of the CLI, since nothing can be piped or redirected from a -process that never ran. - -Scope of the claim, measured: this is specific to **non-interactive parents**. -An interactive `ShellExecute` launch (double-click, a shortcut, `Start-Process` -without `-NoNewWindow`) would raise a UAC prompt and succeed if the user -consents — that path was not tested here. The failure case is the -server/container/CI one, which is exactly what the headless build exists for. - -Fix: gate the manifest on the `gui` feature as well as the profile. An -unelevated CLI should degrade to reading fewer sensors — which is what -`lhm_bridge.rs` already documents — not refuse to launch. +The GUI build still elevates deliberately — Super-I/O, MSR and SMBus sensors +need it. An unelevated CLI now degrades to reading fewer sensors, which is what +`lhm_bridge.rs` already documented, instead of refusing to launch. ### `--help`, `--version` and every clap error print nothing diff --git a/app/.gitignore b/app/.gitignore index a731476b..106328c7 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,4 +1,8 @@ target/ +# The portable build uses its own target dir so it cannot overwrite the binary +# the installer was built from — see release.yml. +target-portable/ +target-portable-headless/ sidecar/publish/ sidecar/bin/ sidecar/obj/ diff --git a/app/Cargo.toml b/app/Cargo.toml index 32306a25..8181fefb 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -85,6 +85,12 @@ gui = ["dep:eframe", "dep:egui_extras", "dep:egui_tiles", "dep:image"] push = ["dep:ureq"] # `sensorview top` — a live terminal dashboard. tui = ["dep:ratatui", "dep:crossterm"] +# Compile the LibreHardwareMonitor sidecar into the binary so a single +# Windows .exe runs with nothing beside it. Off by default: it adds ~72 MB, +# which only the portable release artifact wants to pay. Requires the sidecar +# to have been published first — build.rs says so if it hasn't. A no-op on +# macOS and Linux, which read their sensors natively. +portable = [] # The LAN dashboard tier. Compiling without it drops tokio/axum entirely and # yields a pure-GUI binary. web = [ diff --git a/app/build.rs b/app/build.rs index d8f825d5..4a1edd3d 100644 --- a/app/build.rs +++ b/app/build.rs @@ -1,6 +1,43 @@ fn main() { #[cfg(windows)] windows_resources(); + embed_portable_sidecar(); +} + +/// `--features portable` compiles the LibreHardwareMonitor sidecar *into* the +/// binary, so one .exe is genuinely self-contained. +/// +/// Without it a lone `sensorview.exe` on Windows is not a portable build at +/// all: `default_source()` falls back to `LhmBridge::empty` when the sidecar +/// is not on disk beside it, and the app opens reporting no sensors whatsoever. +/// +/// Only meaningful for a Windows target — macOS and Linux read their sensors +/// natively and have no sidecar to carry. +fn embed_portable_sidecar() { + println!("cargo:rerun-if-changed=sidecar/publish/sensorview-bridge.exe"); + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_PORTABLE"); + + if std::env::var("CARGO_FEATURE_PORTABLE").is_err() + || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") + { + return; + } + + let src = std::path::Path::new("sidecar/publish/sensorview-bridge.exe"); + if !src.exists() { + // Failing loudly beats emitting a portable build whose whole selling + // point silently does not work. + panic!( + "--features portable needs the sidecar at {}; run \ + `dotnet publish sidecar -c Release -o sidecar/publish` first", + src.display() + ); + } + + let out = std::path::PathBuf::from( + std::env::var("OUT_DIR").expect("OUT_DIR is always set for a build script"), + ); + std::fs::copy(src, out.join("bridge.bin")).expect("copy the sidecar into OUT_DIR"); } // `winresource` is a Windows-host-only build-dependency (see Cargo.toml), so @@ -10,17 +47,27 @@ fn main() { #[cfg(windows)] fn windows_resources() { // Embed the app icon + version info into the Windows executable, and — for - // release builds only — a requireAdministrator manifest so the app elevates - // at launch like HWiNFO does (full sensor access needs admin: Super-I/O, - // MSR, SMBus via the kernel driver). Debug builds stay asInvoker so - // `cargo test` / dev runs don't trip UAC. + // release GUI builds only — a requireAdministrator manifest so the app + // elevates at launch like HWiNFO does (full sensor access needs admin: + // Super-I/O, MSR, SMBus via the kernel driver). Debug builds stay asInvoker + // so `cargo test` / dev runs don't trip UAC. + // + // Gated on the `gui` feature as well as the profile. Keying on the profile + // alone also stamped the manifest onto the *headless* release binary — the + // one offered for servers and containers — and a non-elevated parent then + // could not start it at all: CreateProcess fails outright with + // ERROR_ELEVATION_REQUIRED, with no UAC prompt available to a service, + // scheduled task or container entrypoint. A CLI that cannot see MSR + // sensors should report fewer sensors, not refuse to launch. // // macOS needs no equivalent: sensors there are read through IOKit with no // driver and no elevation, and cargo-packager generates the Info.plist. if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { let mut res = winresource::WindowsResource::new(); res.set_icon("assets/icon.ico"); - if std::env::var("PROFILE").as_deref() == Ok("release") { + if std::env::var("PROFILE").as_deref() == Ok("release") + && std::env::var("CARGO_FEATURE_GUI").is_ok() + { res.set_manifest( r#" diff --git a/app/src/cli/procs.rs b/app/src/cli/procs.rs index c18f49d1..830e5955 100644 --- a/app/src/cli/procs.rs +++ b/app/src/cli/procs.rs @@ -17,6 +17,7 @@ const READY_TIMEOUT: Duration = Duration::from_secs(8); pub enum SortKey { Cpu, Mem, + Disk, Pid, Name, } @@ -26,6 +27,7 @@ impl From for Sort { match k { SortKey::Cpu => Sort::Cpu, SortKey::Mem => Sort::Memory, + SortKey::Disk => Sort::Disk, SortKey::Pid => Sort::Pid, SortKey::Name => Sort::Name, } @@ -82,8 +84,8 @@ pub fn run( pub fn render_table(rows: &[ProcessRow], cpu_count: usize) -> String { let mut out = String::new(); out.push_str(&format!( - "{:>7} {:>6} {:<12} {:>9} {:>9} {}\n", - "PID", "CPU%", "USER", "MEM", "VIRT", "NAME" + "{:>7} {:>6} {:<12} {:>9} {:>9} {:>10} {}\n", + "PID", "CPU%", "USER", "MEM", "VIRT", "DISK", "NAME" )); for r in rows { @@ -93,12 +95,13 @@ pub fn render_table(rows: &[ProcessRow], cpu_count: usize) -> String { Some(v) => format!("{v:.1}"), }; out.push_str(&format!( - "{:>7} {:>6} {:<12} {:>9} {:>9} {}\n", + "{:>7} {:>6} {:<12} {:>9} {:>9} {:>10} {}\n", r.pid, cpu, truncate(r.user.as_deref().unwrap_or("—"), 12), procs::format_bytes(r.mem_bytes), procs::format_bytes(r.virt_bytes), + procs::format_rate(r.disk_bps), r.name, )); } @@ -168,6 +171,30 @@ mod tests { assert!(out.contains("800%"), "{out}"); } + /// The disk column belongs to the headless build too, not just the GUI. + /// + /// This is load-bearing beyond cosmetics: when `Sort::Disk` and + /// `format_rate` were reachable only from the Task Manager window, a + /// `--no-default-features` build saw them as dead code and CI failed on + /// `-D warnings` for every GUI-less feature combination. Exercising them + /// from the CLI is what keeps that honest, so this test failing is the + /// signal that the headless build is about to break again. + #[test] + fn disk_rate_appears_in_the_table_and_distinguishes_idle_from_unknown() { + let mut busy = row(1, "writer", Some(1.0), Some("me")); + busy.disk_bps = Some(3.0 * 1024.0 * 1024.0); + let mut idle = row(2, "quiet", Some(1.0), Some("me")); + idle.disk_bps = Some(0.0); + // `unknown` keeps disk_bps: None from the fixture. + let unknown = row(3, "cold", Some(1.0), Some("me")); + + let out = render_table(&[busy, idle, unknown], 8); + assert!(out.contains("DISK"), "header must carry the column:\n{out}"); + assert!(out.contains("3.0 MB/s"), "{out}"); + assert!(out.contains("0 MB/s"), "measured idle should read as zero:\n{out}"); + assert!(out.contains('—'), "an unread rate must stay unknown:\n{out}"); + } + #[test] fn empty_result_says_so_rather_than_printing_a_bare_header() { let out = render_table(&[], 8); @@ -190,6 +217,7 @@ mod tests { fn sort_key_maps_onto_the_collector_ordering() { assert_eq!(Sort::from(SortKey::Cpu), Sort::Cpu); assert_eq!(Sort::from(SortKey::Mem), Sort::Memory); + assert_eq!(Sort::from(SortKey::Disk), Sort::Disk); assert_eq!(Sort::from(SortKey::Pid), Sort::Pid); assert_eq!(Sort::from(SortKey::Name), Sort::Name); } diff --git a/app/src/main.rs b/app/src/main.rs index abafe735..d7caa969 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -46,6 +46,9 @@ mod format; mod inventory; mod logging; mod model; +/// Windows-only, and only when the sidecar is compiled in — see the module doc. +#[cfg(all(windows, feature = "portable"))] +mod portable; mod poll; mod procs; mod report; diff --git a/app/src/portable.rs b/app/src/portable.rs new file mode 100644 index 00000000..b479dd47 --- /dev/null +++ b/app/src/portable.rs @@ -0,0 +1,105 @@ +//! The portable Windows build: the sensor sidecar travels inside the binary. +//! +//! A single `sensorview.exe` is not a portable app on Windows on its own. +//! Every sensor on this platform comes from the LibreHardwareMonitor sidecar, +//! and [`crate::source::default_source`] falls back to `LhmBridge::empty` when +//! it cannot find one — so a lone executable opens to an empty window. Building +//! with `--features portable` compiles the sidecar in (see `build.rs`); this +//! module unpacks it on first run so there is something to spawn. +//! +//! Unpacked to a per-version directory under the local app data folder rather +//! than beside the executable: a portable binary is expected to run from +//! read-only media, a network share or a USB stick, and must not assume it can +//! write next to itself. + +use std::path::PathBuf; + +/// The sidecar, as copied into `OUT_DIR` by `build.rs`. +const BRIDGE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bridge.bin")); + +const SIDECAR_EXE: &str = "sensorview-bridge.exe"; + +/// Unpack the embedded sidecar if needed and return its path. +/// +/// `None` when there is nowhere to write it, which leaves the caller to carry +/// on with its other candidates rather than failing outright. +pub fn ensure_sidecar() -> Option { + // Keyed by version *and* length so an upgraded app never reuses the + // previous release's sidecar, which would silently pair a new front end + // with an old bridge protocol. + let dir = dirs::data_local_dir()? + .join("SensorView") + .join("bridge") + .join(format!("{}-{}", env!("CARGO_PKG_VERSION"), BRIDGE.len())); + let exe = dir.join(SIDECAR_EXE); + + if is_complete(&exe) { + return Some(exe); + } + + std::fs::create_dir_all(&dir).ok()?; + + // Write to a private name and rename into place. A half-written 72 MB file + // that the *next* run mistakes for a complete one is worse than not + // unpacking at all, and two instances starting together must not interleave + // their writes into the same file. + let tmp = dir.join(format!("{SIDECAR_EXE}.{}.tmp", std::process::id())); + if std::fs::write(&tmp, BRIDGE).is_err() { + let _ = std::fs::remove_file(&tmp); + return None; + } + + match std::fs::rename(&tmp, &exe) { + Ok(()) => Some(exe), + Err(_) => { + // On Windows rename fails when the destination exists, which is + // exactly what another instance winning the race looks like. Use + // its copy if it is complete. + let _ = std::fs::remove_file(&tmp); + is_complete(&exe).then_some(exe) + } + } +} + +/// Whether an already-unpacked sidecar is the one we carry, judged by size. +/// +/// Cheap on purpose: this runs on every start, and re-hashing 72 MB to prove +/// what the per-version directory name already implies would cost more than it +/// tells us. +fn is_complete(path: &std::path::Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.len() == BRIDGE.len() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The build script must have supplied a real sidecar. An empty blob would + /// compile, ship, and then fail at runtime with nothing to spawn — the one + /// failure this feature exists to prevent. + #[test] + fn the_embedded_sidecar_is_a_real_executable() { + assert!( + BRIDGE.len() > 1024 * 1024, + "embedded sidecar is only {} bytes — did build.rs copy it?", + BRIDGE.len() + ); + assert_eq!(&BRIDGE[..2], b"MZ", "embedded blob is not a PE executable"); + } + + /// The unpack directory has to change when the payload does, or an upgrade + /// keeps running the previous release's bridge. + #[test] + fn the_unpack_path_is_keyed_to_this_build() { + let Some(p) = ensure_sidecar() else { + eprintln!("SKIP: no local data directory on this machine"); + return; + }; + let key = format!("{}-{}", env!("CARGO_PKG_VERSION"), BRIDGE.len()); + assert!( + p.to_string_lossy().contains(&key), + "unpack path {p:?} is not keyed by version and payload size" + ); + assert!(is_complete(&p), "unpacked sidecar is the wrong size"); + } +} diff --git a/app/src/source/lhm_bridge.rs b/app/src/source/lhm_bridge.rs index 064c02ca..0d1fcb27 100644 --- a/app/src/source/lhm_bridge.rs +++ b/app/src/source/lhm_bridge.rs @@ -189,7 +189,8 @@ fn kill_stale_sidecars() { } /// Locate the sidecar: next to our exe (packaged install), then the dev -/// publish folder (repo checkout). +/// publish folder (repo checkout), then — in a `portable` build — the copy +/// carried inside the binary. fn find_sidecar() -> Option { let mut candidates: Vec = Vec::new(); if let Ok(me) = std::env::current_exe() { @@ -232,5 +233,18 @@ fn find_sidecar() -> Option { .join(SIDECAR_EXE), ); } - candidates.into_iter().find(|p| p.is_file()) + if let Some(found) = candidates.into_iter().find(|p| p.is_file()) { + return Some(found); + } + + // Last: the copy a portable build carries inside itself. Deliberately after + // the on-disk candidates, so an installed layout or a developer's freshly + // published sidecar still wins — and so the 72 MB unpack only happens when + // there is genuinely nothing else to run. + #[cfg(all(windows, feature = "portable"))] + { + return crate::portable::ensure_sidecar(); + } + #[cfg(not(all(windows, feature = "portable")))] + None }