Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 74 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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_<ver>_x64-setup.exe` (NSIS) | Bundles the sensor sidecar; optionally installs the PawnIO driver |
| Windows | `SensorView-<ver>-portable.exe` | One file, no install — see below |
| macOS | `SensorView_<ver>_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**.
Expand Down
44 changes: 20 additions & 24 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
6 changes: 6 additions & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
57 changes: 52 additions & 5 deletions app/build.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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#"<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
Expand Down
34 changes: 31 additions & 3 deletions app/src/cli/procs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const READY_TIMEOUT: Duration = Duration::from_secs(8);
pub enum SortKey {
Cpu,
Mem,
Disk,
Pid,
Name,
}
Expand All @@ -26,6 +27,7 @@ impl From<SortKey> for Sort {
match k {
SortKey::Cpu => Sort::Cpu,
SortKey::Mem => Sort::Memory,
SortKey::Disk => Sort::Disk,
SortKey::Pid => Sort::Pid,
SortKey::Name => Sort::Name,
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
));
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading