Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b3f0f9f
fix(installer): register the SMBus broker during Windows install
hyperb1iss Jul 26, 2026
0c4c037
fix(windows): splat hardware-support arguments by hashtable
hyperb1iss Jul 26, 2026
3ef1e1a
fix(sensors): re-probe the Windows CPU temperature reader
hyperb1iss Jul 26, 2026
f9ef61b
style(tests): reflow a packaging assertion to satisfy rustfmt
hyperb1iss Jul 26, 2026
076594d
feat(windows): add a Desktop Duplication capture crate
hyperb1iss Jul 26, 2026
0b4ca71
feat(windows): wire screen capture into the input manager
hyperb1iss Jul 26, 2026
e3089bf
build(windows): fail fast when a foreign Rust shadows rustup
hyperb1iss Jul 26, 2026
4c6c900
fix(input): collapse the evdev wheel arm to satisfy clippy
hyperb1iss Jul 26, 2026
1eb3d00
fix(windows): box-filter the capture readback instead of point sampling
hyperb1iss Jul 26, 2026
409731d
fix(capture): stop letterbox detection from eating the whole frame
hyperb1iss Jul 26, 2026
6a75ebd
build: refresh Cargo.lock for the capture crate dev-dependency
hyperb1iss Jul 26, 2026
3bacb0f
fix(ui): route bundled presets to the zone instead of a dead endpoint
hyperb1iss Jul 26, 2026
394f191
fix(capture): keep the screen downscale at the source aspect ratio
hyperb1iss Jul 26, 2026
ca1cda5
feat(capture): enumerate monitors and serve them over the API
hyperb1iss Jul 26, 2026
e66fe9a
feat(ui): reduce capture settings to on, monitor, and a disclosure
hyperb1iss Jul 26, 2026
1d406bc
fix(capture): gate the Windows error constructor to Windows
hyperb1iss Jul 26, 2026
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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 36 additions & 14 deletions crates/hypercolor-app/installer-hooks.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,55 @@
;
; Tauri's templated NSIS installer handles the file/registry steps,
; but it has no knowledge of our hardware access stack (PawnIO kernel
; driver + Windows Firewall exception), and no concept of cleaning that
; stack up on uninstall. These hooks fill that gap while keeping
; privileged SMBus broker setup as an explicit administrator action.
; driver + SMBus broker service + Windows Firewall exception), and no
; concept of cleaning that stack up on uninstall. These hooks fill
; that gap.
;
; Wired in via bundle.windows.nsis.installerHooks in
; tauri.windows.bundle.conf.json. The installer runs elevated
; (installMode = perMachine), so sc.exe / netsh / PawnIO_setup.exe
; all inherit the rights they need.

!macro NSIS_HOOK_POSTINSTALL
; PawnIO kernel driver — silent install of the bundled installer.
; Skips if PawnIO is already present (the script's Resolve-PawnIoHome
; short-circuits the setup.exe run). Modules are copied into PawnIO's
; install dir so the broker can find them via pawnio_module_dirs().
; Hardware access stack: PawnIO kernel driver plus the HypercolorSmBus
; broker, installed in one elevated pass. The orchestrator installs
; PawnIO (short-circuiting when already present), copies the verified
; module blobs into PawnIO's install dir, then registers and starts the
; broker.
;
; Both halves are load-bearing. The broker is what loads PawnIO modules
; on behalf of the unelevated daemon, so skipping it leaves CPU package
; temperature and motherboard/DRAM SMBus lighting permanently dark with
; no error the user can act on.
;
; Install time is the only moment Hypercolor holds administrator rights.
; An unelevated app cannot register a LocalSystem service, so deferring
; this means either an out-of-nowhere UAC prompt later or — as shipped in
; 0.2.1 — silently broken hardware. Every path handed to the orchestrator
; sits under $INSTDIR (Program Files under perMachine), which satisfies
; the broker installer's own rejection of user-writable service paths.
;
; -ReinstallService keeps upgrades idempotent: the broker installer
; refuses to clobber an existing registration without it.
;
; The bundled script propagates Windows installer exit code 3010
; ("reboot required") when the kernel driver needs a restart to finish
; binding to SCM. We stash that into $R0 so we can prompt the user to
; restart at the end of postinstall.
DetailPrint "Installing PawnIO hardware access (this may take a moment)..."
nsExec::ExecToLog 'powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\tools\install-bundled-pawnio.ps1" -AssetRoot "$INSTDIR\tools\pawnio" -Silent'
DetailPrint "Installing Hypercolor hardware access (this may take a moment)..."
nsExec::ExecToLog 'powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\tools\install-windows-hardware-support.ps1" -AssetRoot "$INSTDIR\tools\pawnio" -BrokerExe "$INSTDIR\tools\hypercolor-smbus-service.exe" -ModuleDestination "$INSTDIR\tools\pawnio\modules" -Silent -ReinstallService'
Pop $R0
DetailPrint " PawnIO install exit code: $R0"
DetailPrint " Hardware access exit code: $R0"

; HypercolorSmBus broker service is no longer auto-installed by default.
; Installing and starting a privileged broker remains an explicit
; administrator action outside the main installer.
; A failed hardware-access pass must not fail the install — Hypercolor
; still drives every USB and network device without it. Say so plainly
; in the details log so a support request has something to quote.
${If} $R0 <> 0
${AndIf} $R0 <> 3010
DetailPrint " Hardware access setup did not complete. USB and network"
DetailPrint " lighting still work; motherboard SMBus lighting and CPU"
DetailPrint " temperature need Settings > Discovery > Hardware Support."
${EndIf}

; Windows Firewall — pre-grant the daemon so mDNS discovery and any
; future inbound traffic don't trigger the "Allow on public networks?"
Expand All @@ -51,7 +73,7 @@
; of letting them launch Hypercolor into a broken hardware-access
; state.
${If} $R0 = 3010
MessageBox MB_YESNO|MB_ICONQUESTION "Hypercolor installed successfully, but the PawnIO kernel driver needs a Windows restart before hardware lighting can come online. Restart now?" IDNO no_reboot_now
MessageBox MB_YESNO|MB_ICONQUESTION "Hypercolor installed successfully, but the PawnIO kernel driver needs a Windows restart before motherboard lighting and CPU temperature can come online. Restart now?" IDNO no_reboot_now
Reboot
no_reboot_now:
${EndIf}
Expand Down
68 changes: 68 additions & 0 deletions crates/hypercolor-app/tests/packaging_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const PACKAGE_DEB_SH: &str = include_str!("../../../scripts/package-deb.sh");
const VERIFY_DEB_SH: &str = include_str!("../../../scripts/verify-deb-package.sh");
const STAGE_APP_BUNDLE_PS1: &str = include_str!("../../../scripts/stage-app-bundle-assets.ps1");
const STAGE_APP_BUNDLE_SH: &str = include_str!("../../../scripts/stage-app-bundle-assets.sh");
const INSTALLER_HOOKS_NSH: &str = include_str!("../installer-hooks.nsh");
const INSTALL_WINDOWS_HARDWARE_SUPPORT_PS1: &str =
include_str!("../../../scripts/install-windows-hardware-support.ps1");

const WINDOWS_TOOL_SCRIPTS: &[&str] = &[
"install-windows-service.ps1",
Expand Down Expand Up @@ -130,6 +133,71 @@ fn app_bundle_staging_includes_pawnio_runtime_payloads() {
}
}

/// 0.2.1 shipped PawnIO without the broker that loads its modules, so CPU
/// package temperature and motherboard SMBus lighting were dead on every
/// Windows install with nothing prompting for the rights to fix it.
#[test]
fn installer_hook_provisions_the_whole_hardware_access_stack() {
assert!(
INSTALLER_HOOKS_NSH.contains("install-windows-hardware-support.ps1"),
"postinstall must run the orchestrator that installs PawnIO *and* the SMBus broker"
);
assert!(
INSTALLER_HOOKS_NSH.contains(r#"-BrokerExe "$INSTDIR\tools\hypercolor-smbus-service.exe""#),
"the broker must be registered from its Program Files path"
);
assert!(
INSTALLER_HOOKS_NSH.contains("-ReinstallService"),
"upgrades must not trip the broker installer's existing-registration guard"
);
}

/// The broker installer rejects service binaries and PawnIO directories under
/// per-user profile paths, since a LocalSystem service must not load code the
/// user can rewrite. Everything the hook hands it therefore lives in $INSTDIR.
#[test]
fn installer_hook_keeps_privileged_paths_administrator_owned() {
for flag in ["-AssetRoot", "-BrokerExe", "-ModuleDestination"] {
let value = INSTALLER_HOOKS_NSH
.split_once(&format!("{flag} \""))
.map(|(_, rest)| rest)
.and_then(|rest| rest.split_once('"'))
.map(|(value, _)| value)
.unwrap_or_else(|| panic!("installer hook should pass {flag}"));
assert!(
value.starts_with("$INSTDIR\\"),
"{flag} must stay under $INSTDIR, got {value}"
);
}
}

/// Splatting an array into a PowerShell *script* binds every element
/// positionally, so `@("-AssetRoot", $path)` puts the literal string
/// "-AssetRoot" in the child's first parameter and drops every switch on the
/// floor. Only native executables parse "-Name value" pairs out of a splatted
/// array; scripts need a hashtable. This silently broke both the installer
/// path and the app's "Install support" button.
#[test]
fn hardware_support_orchestrator_splats_by_hashtable_not_array() {
for splat in ["$pawnIoArgs", "$serviceArgs"] {
let opener = format!("{splat} = @");
let (_, rest) = INSTALL_WINDOWS_HARDWARE_SUPPORT_PS1
.split_once(opener.as_str())
.unwrap_or_else(|| panic!("orchestrator should build {splat}"));
assert!(
rest.starts_with('{'),
"{splat} must be a hashtable; an array splat binds positionally \
and silently mis-assigns every named parameter"
);
}
}

#[test]
fn installer_hook_cleans_up_the_broker_on_uninstall() {
assert!(INSTALLER_HOOKS_NSH.contains("sc.exe stop HypercolorSmBus"));
assert!(INSTALLER_HOOKS_NSH.contains("sc.exe delete HypercolorSmBus"));
}

#[test]
fn brand_build_pipeline_mirrors_nsis_assets_to_tauri_icons() {
assert!(BRAND_BUILD_PY.contains("crates\" / \"hypercolor-app\" / \"icons"));
Expand Down
1 change: 1 addition & 0 deletions crates/hypercolor-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ raw-window-handle = { version = "0.6", optional = true }
tao = { version = "0.34", default-features = false, features = ["rwh_06"], optional = true }
wmi = { workspace = true }
hypercolor-windows-pawnio = { path = "../hypercolor-windows-pawnio" }
hypercolor-windows-capture = { path = "../hypercolor-windows-capture" }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
Expand Down
102 changes: 102 additions & 0 deletions crates/hypercolor-core/examples/dump_screen_canvas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Dump the screen-capture canvas that screen-reactive effects actually see.
//!
//! Runs the real pipeline — platform capture backend into `ScreenCaptureInput`
//! — and writes the published `canvas_downscale` surface to PNG. That surface
//! is what `screen_cast` samples and what ambilight's sector grid is computed
//! from, so it is the honest answer to "why does the screen look wrong".
//!
//! ```text
//! cargo run --example dump_screen_canvas -p hypercolor-core -- out.png
//! ```

fn main() {
#[cfg(not(target_os = "windows"))]
{
eprintln!("this example currently drives the Windows capture backend only");
std::process::exit(1);
}

#[cfg(target_os = "windows")]
{
use std::time::{Duration, Instant};

use hypercolor_core::input::screen::{CaptureConfig, ScreenCaptureInput};
use hypercolor_core::input::{InputData, InputSource};
use hypercolor_windows_capture::DesktopDuplicator;

let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "screen-canvas.png".to_owned());

let mut duplicator = match DesktopDuplicator::new(0, 1280) {
Ok(duplicator) => duplicator,
Err(error) => {
eprintln!("failed to open capture: {error}");
std::process::exit(1);
}
};
let mut analyzer = ScreenCaptureInput::new(CaptureConfig::default());
if let Err(error) = analyzer.start() {
eprintln!("failed to start analyzer: {error}");
std::process::exit(1);
}

let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
match duplicator.next_frame(Duration::from_millis(200)) {
Ok(Some(frame)) => {
println!("capture backend produced {}x{}", frame.width, frame.height);
analyzer.push_frame(frame.rgba, frame.width, frame.height);
}
Ok(None) => continue,
Err(error) => {
eprintln!("capture failed: {error}");
std::process::exit(1);
}
}

let Ok(InputData::Screen(data)) = analyzer.sample() else {
continue;
};
let Some(surface) = data.canvas_downscale.as_ref() else {
println!("no canvas_downscale published yet");
continue;
};

let descriptor = surface.descriptor();
let canvas = hypercolor_core::types::canvas::Canvas::from_published_surface(surface);
let bytes = canvas.as_rgba_bytes().to_vec();
println!(
"canvas_downscale: {}x{} ({} bytes), source was {}x{}",
descriptor.width,
descriptor.height,
bytes.len(),
data.source_width,
data.source_height
);
println!(
"letterbox bars (grid units, t/b/l/r): {:?} of a {}x{} grid",
data.letterbox, data.grid_width, data.grid_height
);
println!("zone colors published: {}", data.zone_colors.len());

let Some(buffer) =
image::RgbaImage::from_raw(descriptor.width, descriptor.height, bytes)
else {
eprintln!("canvas bytes did not match the descriptor");
std::process::exit(1);
};
match buffer.save(&path) {
Ok(()) => println!("wrote {path}"),
Err(error) => {
eprintln!("failed to write {path}: {error}");
std::process::exit(1);
}
}
return;
}

eprintln!("no frame arrived within the budget");
std::process::exit(1);
}
}
30 changes: 14 additions & 16 deletions crates/hypercolor-core/src/input/evdev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,23 +664,21 @@ fn fold_event(
event_limit,
);
}
RelativeAxisCode::REL_WHEEL => {
// Devices with hi-res wheels report both; keep only the
// hi-res stream to avoid double counting.
if !open.caps.hi_res_wheel {
push_event(
state,
TimedInputEvent {
event: InputEvent::MouseWheel {
source_id: open.source_id.clone(),
delta_hi_res: value.saturating_mul(120),
},
at_ms,
seq: 0,
// Devices with hi-res wheels report both; keep only the
// hi-res stream to avoid double counting.
RelativeAxisCode::REL_WHEEL if !open.caps.hi_res_wheel => {
push_event(
state,
TimedInputEvent {
event: InputEvent::MouseWheel {
source_id: open.source_id.clone(),
delta_hi_res: value.saturating_mul(120),
},
event_limit,
);
}
at_ms,
seq: 0,
},
event_limit,
);
}
_ => {}
}
Expand Down
Loading
Loading