From e042e071cc9f8dc586e755791a7fa8790c8f7529 Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe <73810867+manupawickramasinghe@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:45:45 +0530 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Give=20`ps`=20the=20disk=20c?= =?UTF-8?q?olumn=20so=20headless=20builds=20stop=20failing=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort::Disk and format_rate were reachable only from the Task Manager window, so every GUI-less feature combination saw them as dead code and clippy's -D warnings turned that into a build failure: error: variant `Disk` is never constructed error: function `format_rate` is never used The fix is parity rather than an allow(dead_code): `sensorview ps` is meant to be the same data from a terminal, and it was the one view of a process that could not show what a process is doing to the disk. It now has a DISK column and `--sort disk`, which also exercises both items from the headless build. The new test says so explicitly, so the next person to see it fail knows it is guarding the headless build rather than checking a cosmetic. Verified with ci.yml's own matrix — all seven feature combinations, including the bare `--no-default-features` one that broke. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- app/src/cli/procs.rs | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index abc66f81..588c6ac8 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 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); } From 5cb3fd6f7f3ac0fbac4076d8607a4c8214384dcc Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe <73810867+manupawickramasinghe@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:46:16 +0530 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Ship=20a=20portable=20Windows?= =?UTF-8?q?=20exe,=20and=20let=20headless=20builds=20run=20unelevated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow attached an installer, a .deb, an .AppImage and a .dmg, but no portable build. Adding one turned out to need a code change rather than a workflow line. A lone sensorview.exe is not a portable app on Windows. Every sensor on this platform comes from the LibreHardwareMonitor sidecar, and default_source() falls back to LhmBridge::empty when it cannot find one beside the binary — so a bare .exe opens reporting nothing at all. The new `portable` feature compiles the sidecar into the binary and unpacks it to %LOCALAPPDATA% on first run, keyed by version and payload size so an upgrade never reuses the previous release's bridge. It writes to a private name and renames into place, because a half-written 70 MB file that the next run mistakes for complete is worse than not unpacking at all. Unpacked there rather than beside the executable on purpose: a portable binary is expected to run from a USB stick or a network share and must not assume it can write next to itself. The feature is off by default — it costs ~70 MB, which only this artifact wants to pay — and is a no-op on macOS and Linux, which read their sensors natively. build.rs fails loudly if the sidecar has not been published, rather than emitting a portable build whose whole selling point silently does not work. CI now builds it on Windows so it cannot rot between tags. Separately, build.rs gated the requireAdministrator manifest on the profile alone, so the *headless* release binary — the one offered for servers and containers — also demanded admin, and a non-elevated parent could not start it at all (CreateProcess, ERROR_ELEVATION_REQUIRED, with no UAC prompt available to a service or container). Now gated on the `gui` feature too: the GUI still elevates deliberately, the CLI degrades to fewer sensors instead of refusing to launch. The release job also checks that each platform actually produced its artifacts. Publishing attaches whatever the globs match, so a format that failed to build would otherwise surface as a release quietly missing a platform. Verified: the portable binary run alone in an empty directory unpacks its bridge and reports real sensors, and the headless release binary now starts unelevated, which it previously could not. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++ .github/workflows/release.yml | 80 ++++++++++++++++++++++++-- README.md | 10 +++- TODO.md | 44 +++++++------- app/.gitignore | 4 ++ app/Cargo.toml | 6 ++ app/build.rs | 57 ++++++++++++++++-- app/src/main.rs | 3 + app/src/portable.rs | 105 ++++++++++++++++++++++++++++++++++ app/src/source/lhm_bridge.rs | 18 +++++- 10 files changed, 296 insertions(+), 38 deletions(-) create mode 100644 app/src/portable.rs 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 588c6ac8..9fc84d17 100644 --- a/README.md +++ b/README.md @@ -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/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 }