Skip to content

fix(windows): repair first-run hardware access, land screen capture, fix the capture pipeline#124

Merged
hyperb1iss merged 16 commits into
mainfrom
fix/windows-first-run-hardware-access
Jul 26, 2026
Merged

fix(windows): repair first-run hardware access, land screen capture, fix the capture pipeline#124
hyperb1iss merged 16 commits into
mainfrom
fix/windows-first-run-hardware-access

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Windows shipped in 0.2.1 with its hardware access stack half-installed, no
screen capture backend at all, and a capture pipeline that mangled what it did
capture. This PR fixes the install path end to end, lands Desktop Duplication
capture, repairs four pipeline defects that made screen effects look broken on
every platform, and unblocks the CI that had been red on main since
2026-07-21.

Everything was diagnosed against a stock 0.2.1 install or a live 4K desktop
and verified on real hardware — receipts inline.

The 0.2.1 Windows install was broken in three stacked ways

A Corsair AIO LCD showing 0% turned out to be the visible end of a chain:

  1. The installer never registered the SMBus broker. Removed in d79482d to
    keep broker setup "an explicit administrator action" — but install time is
    the only moment the app holds administrator rights, so the fallback was
    nothing at all. The NSIS hook now runs the hardware-support orchestrator
    (PawnIO + verified modules + broker) in one elevated pass, with every path
    under $INSTDIR so the broker installer's rejection of user-writable
    service paths still holds.
  2. The fallback path was itself broken. install-windows-hardware-support.ps1
    splatted an array into a PowerShell script; array splatting binds
    positionally, so -AssetRoot arrived as a parameter value, everything
    shifted one slot, and no switch ever bound. Settings → "Install support"
    has therefore never worked. Now hashtable splatting, with a packaging test
    pinning it.
  3. The daemon cached the failed CPU-temp probe forever. The PawnIO reader
    now re-probes on a 20s backoff and drops a dead reader so a broker
    restarted by SCM heals on its own.

Evidence: on a clean 0.2.1 install, PawnIO was Running with modules
deployed while HypercolorSmBus did not exist. Running the fixed orchestrator
elevated brought two previously invisible ASUS Aura DRAM devices up over SMBus
without a daemon restart (device count 14 → 16, one rendering immediately).

Windows screen capture now exists

build_input_manager registered a screen source only under
cfg(target_os = "linux"); toggling capture.enabled on Windows did nothing.

hypercolor-windows-capture adds a DXGI Desktop Duplication backend — its own
crate because the workspace forbids unsafe_code and COM interop cannot,
matching the existing Windows FFI crates. Desktop Duplication is the
compositor's own presented output: free while the screen is static, no border,
no picker, and no permission grant (Windows has no portal/TCC equivalent).

  • capture.enabled defaults to true on Windows only. Flagged
    explicitly: there is nothing to consent to on Windows, so the toggle
    protected nothing. Portal (Linux) and TCC (macOS) are real consent moments
    and stay opt-in. Enabling only grants permission — capture opens on effect
    demand and the duplication interface is released when idle, since Windows
    allows one per output per process.
  • Readback box-filters during the copy. The first cut point-sampled;
    wrong, because the buffer is also published as canvas_downscale and
    consumed as an image, then downscaled again — two point samplings of a 4K
    desktop shred text into aliased noise. Found by dumping real frames
    (dump_frame / dump_screen_canvas examples are in-tree).

Four capture pipeline defects, all platforms

These predate the Windows backend and explain "ambilight looks like garbage":

  1. Letterbox detection ate the whole frame. pixel_luminance is linear;
    sRGB 30/255 is 0.013 linear, under the 0.02 "black bar" threshold — so a
    dark desktop read as bars from every edge. Measured live: an 8×6 grid
    reported bars [6,6,8,8] (the entire grid), cropping the picture to
    nothing and strobing as content changed. Detection now refuses bars that
    swallow an axis, and letterbox defaults off — desktops are not
    letterboxed films.
  2. The downscale squashed 16:9 into 4:3. 1280×720 → 640×480 is a 1.33×
    vertical stretch baked into the pixels before any effect sees them. Now
    fits within the canvas bounds: 4K publishes 640×360, portrait and
    ultrawide scale on the binding axis.
  3. Bundled presets could never apply. They PATCHed the legacy global
    /effects/current/controls, which 404s in a zone scene; the error handler
    restored the previous dropdown selection, so presets visibly "refused to
    stick" and Screen Cast stayed pinned to Center Zoom's 40% crop. They now
    take the same zone-scoped route as manual control edits.
  4. Smoothing default couldn't keep up (persisted configs at 0.05 = ~600ms
    color lag; relabeled "Color response" with a sane default).

Monitor selection and a usable settings panel

  • GET /api/v1/capture/monitors serves real outputs (index, name, extent,
    primary) with ready-to-store capture.source values; monitor:N /
    display:N parsing lands in core. Verified against real hardware including
    a portrait 1440×2560 secondary.
  • The Screen Capture settings section drops from thirteen flat engine knobs
    (with a "Source" dropdown offering literally "PipeWire" on Windows) to two
    decisions — enabled, and which monitor — plus a collapsed Advanced
    disclosure with human-language descriptions. No keys removed or renamed.
    Portal platforms get an empty monitor list and keep the picker button.

CI

main had been red since 2026-07-21: one clippy::collapsible_match in the
Linux-only evdev reader. Fixed here (behaviour-preserving match guard) — and
this PR hit the mirror-image trap once itself (a Windows-only constructor dead
on the Linux check), which is its own commit. A stale Cargo.lock from the
dump-example dev-dependency is also fixed.

Also here

scripts/cargo-cache-build.ps1 now asserts the resolved rustc host is MSVC
before building, with the resolved path in the error. A GNU-host Rust
shadowing rustup fails late and selectively (mingw ld errors, E0514
mismatches) and neither symptom names the cause.

Blast radius

  • Runtime changes are Windows-only except: the capture-pipeline fixes
    (letterbox, aspect, presets — deliberate, they were broken everywhere), the
    capture.enabled default (cfg-guarded, unchanged off Windows), and the new
    optional monitor field on core's CaptureConfig.
  • The installer hook is the highest-risk change; it is also the one verified
    most directly on real hardware.
  • No migrations, no persisted-schema changes; reversible.

Verification

  • clippy -D warnings clean: types, core, capture crate, daemon, UI (wasm)
  • 36 types config tests · 49 core screen tests · 10 capture tests (live
    against a 3840×2160 display) · 192 daemon API tests · 18→20 packaging tests
  • Broker fix, letterbox fix, aspect fix, and monitor enumeration each
    verified against live hardware as described above

hyperb1iss and others added 9 commits July 25, 2026 17:23
The 0.2.1 installer ran PawnIO's setup but skipped the HypercolorSmBus
broker, and the broker is what loads PawnIO modules on behalf of the
unelevated daemon. Without it CpuTempReader::new() fails its probe read,
so cpu_temp_celsius stays null forever: motherboard and DRAM SMBus
lighting never appear, and any display face bound to cpu_temp renders a
flat zero. Verified on a clean 0.2.1 install: PawnIO service running,
modules deployed, HypercolorSmBus absent, /api/v1/system/sensors
reporting cpu_temp_celsius null next to a working gpu_temp_celsius.

Install time is the only moment Hypercolor holds administrator rights.
Deferring broker setup meant either an out-of-nowhere UAC prompt later
or silently broken hardware, and in practice it was the latter, because
the only remaining path was a Hardware Support panel gated behind
motherboard detection. Run the existing hardware-support orchestrator
from the postinstall hook instead, which installs PawnIO, stages the
verified module blobs, then registers and starts the broker in one
elevated pass.

Every path handed to the orchestrator sits under $INSTDIR, so the broker
installer's rejection of user-writable service paths still holds; that
guard, not the deferral, is what keeps a LocalSystem service from
loading user-rewritable code. A failed pass no longer implies a failed
install: USB and network lighting work without any of this, and the
details log now says so.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Splatting an array into a PowerShell script binds every element
positionally, so @("-AssetRoot", $path, ...) handed the child script the
literal string "-AssetRoot" as its first parameter, shifted every real
value one slot left, and dropped -Silent, -Force, -Reinstall and -Start
entirely. Only native executables parse "-Name value" pairs out of a
splatted array; scripts need a hashtable. The other splat sites in
scripts/ all target real executables and are fine as they are.

The blast radius was the whole Windows hardware-access story. The
orchestrator is what the app's Settings > Discovery > "Install support"
button runs, and it threw before reaching the broker every single time,
which is why an install could never be repaired from inside the app.
PawnIO itself survived only because the NSIS hook called its installer
directly with real named parameters.

Verified by running the fixed orchestrator elevated against a stock
0.2.1 install: PawnIO reported ready, HypercolorSmBus registered and
started, and the running daemon picked up two previously invisible ASUS
Aura DRAM devices over SMBus without needing a restart.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
WindowsSensorExtras opened the PawnIO reader once at construction and
cached the failure for the daemon's lifetime, so cpu_temp_celsius stayed
null until someone restarted the daemon. That is precisely backwards
from how the broker actually arrives: the installer registers it while
an upgrade's daemon is already running, PawnIO's kernel driver can need
a reboot before it binds to SCM, and Settings can install hardware
support at any moment. Confirmed on this host, where starting the broker
brought SMBus DRAM devices online immediately while CPU temperature
stayed dark.

Retry on a 20 second backoff while the reader is absent, and drop the
reader on a failed read so a stopped or crashed broker heals once SCM
restarts it instead of staying dead. The all-sources-absent early return
is gone: it would have made the retry unreachable on exactly the hosts
that need it, and merge_snapshot already no-ops when every source is
missing.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Whitespace only.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Screen capture on Windows had no backend at all. The only implementation
was WaylandScreenCaptureInput, and build_input_manager registered a
screen source solely under cfg(target_os = "linux"), so toggling
capture.enabled on Windows did nothing whatsoever and every
screen-reactive effect rendered against an empty input.

Desktop Duplication is the right primitive here. It is the compositor's
own presented output, it costs nothing while the screen is static, and
unlike Windows.Graphics.Capture it draws no yellow border and shows no
picker. It also needs no permission grant: Windows has no equivalent of
the XDG portal handshake or macOS TCC prompt, so a screen-reactive
effect can simply work with nothing to consent to.

This lands the FFI layer only; wiring into the input manager follows.
It is a separate crate because the workspace forbids unsafe_code and COM
interop cannot, matching hypercolor-windows-pawnio and
hypercolor-windows-gpu-interop.

Readback subsamples by an integer stride instead of copying native
resolution. Ambient lighting reduces the frame to a coarse sector grid,
so point sampling is indistinguishable in the output while letting the
loop skip most mapped staging rows, which matters when a 4K desktop is
33 MB per frame of write-combined memory. Frames whose LastPresentTime
is zero carry only a pointer move and are dropped before readback, and
access loss from mode changes, full-screen takeover, or the UAC secure
desktop rebuilds the duplication interface in place.

Verified on a 3840x2160 display: the live smoke tests captured real
frames at 1280x720, tightly packed and fully opaque, across repeated
acquisitions. The tests degrade to skips on hosts without a duplicatable
output so the Linux-only CI stays green.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Registers WindowsScreenCaptureInput so Desktop Duplication actually
reaches the render pipeline. A worker thread owns the capture session
and the analysis pipeline and publishes ScreenData snapshots, mirroring
the Wayland source's shape; the render loop only clones the latest
snapshot. The duplication interface opens on demand and is released the
moment capture goes idle, because Windows allows one duplication per
output per process and other ambient-lighting tools want it too.

capture.enabled now defaults to true on Windows and stays false
elsewhere. This is a privacy-relevant default, so the reasoning matters:
Desktop Duplication has no permission prompt, no source picker, and no
capture indicator, meaning there is nothing for a user to consent to and
nothing an opt-in toggle would be protecting. The XDG portal opens a
picker and macOS raises a TCC prompt, and forcing either at daemon start
would be an ambush, so both stay opt-in. Enabling only grants
permission: capture stays closed until a screen-reactive effect creates
demand, so the common case is still no capture at all.

Multi-monitor selection reuses the existing free-form capture.source
field, which the portal leaves at "auto" on Linux. Windows addresses
outputs directly and accepts "monitor:N", "display:N", or a bare index,
falling back to the primary display for anything else so an unknown
value is never an error.

Verified: clippy -D warnings clean on types, core, the capture crate
and the daemon; 36 types config tests, 43 core screen tests, and 9
capture crate tests pass, including a live capture off a 3840x2160
display.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
A second Rust on PATH fails late and selectively. Cached crates check
fine, so a build looks healthy right up until something needs a fresh
link and dies inside a dependency's build script with "cannot execute
'ld'", or hits E0514 rustc-mismatch errors against artifacts the pinned
toolchain left in target/. Neither symptom names the real cause, and
rustup show happily reports the pinned MSVC toolchain while a different
compiler is doing the work.

Chocolatey's `rust` package is the usual culprit: it installs a GNU-host
toolchain under C:\ProgramData\chocolatey\bin, which lives in the system
PATH and therefore beats ~\.cargo\bin in the user PATH. Passing rustup's
cargo.exe by absolute path does not help either, since cargo shells out
to whichever rustc PATH resolves.

Assert the host triple before entering the build, and report the
resolved rustc path plus the removal command. Costs one process spawn
against a class of failure that reads as a broken repo.

Verified both directions: the wrapper builds clean with no PATH
workaround, and a GNU-host rustc placed ahead on PATH aborts with the
explanatory error instead of reaching cargo.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
CI has been red on main since 2026-07-21 with a single lint,
clippy::collapsible_match, fired by the REL_WHEEL arm in the evdev
reader. It failed three jobs because the same code compiles as both lib
and lib test.

The lint never showed up locally on Windows because evdev.rs is behind
cfg(target_os = "linux"), so Windows contributors get a clean clippy run
while Linux-only code rots. The inverse of this trap already bit us the
other way around.

Fold the guard into a match guard as clippy suggests. Behaviour is
identical: a REL_WHEEL event on a hi-res device previously entered the
arm and did nothing, and now falls through to the catch-all instead.

Not verified locally: evdev.rs cannot compile on Windows, and cross-
checking against x86_64-unknown-linux-gnu fails in aws-lc-sys, which
needs a Linux C toolchain. CI is the verifier here.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The readback picked one pixel per stride block. I justified that on the
grounds that ambilight averages the result into a coarse sector grid
anyway, which was the wrong call: the same buffer is published as
canvas_downscale and consumed as an actual image by screen-reactive
effects, which then downscale it a second time. Two successive point
samplings of a 4K desktop shred thin text into aliased noise, and
screen-reactive effects looked correspondingly bad.

The Wayland path never hit this because PipeWire delivers an
already-filtered frame, so the pipeline only ever downscaled once from
something clean. Windows gets the raw desktop and has to do that
filtering itself.

Average every source pixel in the block instead. Reads scale from one
pixel per block to all of them, which is the cost of the frame actually
being correct; the loop stays row-major so the reads are sequential
through mapped staging memory.

Also adds two dump examples, which is how the above was diagnosed rather
than guessed at. Averaged zone colors hide wrong row pitches, swapped
channels, and off-by-one strides equally well, so writing the frame out
is the only way to see what the pipeline really has: dump_frame for the
raw backend output, dump_screen_canvas for the published surface that
effects consume.

Verified by comparing dumps before and after against the same desktop.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Windows DXGI Desktop Duplication backend, integrates it into core, daemon, and UI capture flows, enables Windows-specific defaults and sensor recovery, and updates hardware installation, documentation, tests, examples, and build-toolchain validation.

Changes

Windows support

Layer / File(s) Summary
DXGI capture backend
crates/hypercolor-windows-capture/*
Adds shared capture contracts, Windows Desktop Duplication with staging readback and subsampling, non-Windows stubs, examples, and capture tests.
Core capture integration
crates/hypercolor-core/..., crates/hypercolor-daemon/...
Adds monitor parsing, aspect-preserving downscaling, a background Windows capture worker, daemon registration, and canvas dump examples.
Monitor API and capture settings UI
crates/hypercolor-daemon/src/api/*, crates/hypercolor-ui/...
Adds monitor enumeration APIs and replaces the static source selector with a dynamic monitor picker and advanced tuning panel.
Windows defaults and telemetry recovery
crates/hypercolor-types/..., crates/hypercolor-core/src/input/..., */tests/*
Enables capture by default on Windows, changes letterbox defaults and edge handling, preserves guarded wheel handling, updates tests, and retries failed PawnIO CPU-temperature readers.
Hardware support installation
crates/hypercolor-app/..., scripts/install-windows-hardware-support.ps1
Installs the combined hardware stack through the PowerShell orchestrator, validates named parameters and privileged paths, reports non-fatal failures, and removes the broker service during uninstall.
Preset routing and input handling
crates/hypercolor-ui/..., crates/hypercolor-core/src/input/evdev.rs
Routes bundled presets through scene-scoped zone targets when available and retains the guarded non-hi-res mouse-wheel path.
Platform documentation and build validation
docs/specs/14-screen-capture.md, scripts/cargo-cache-build.ps1
Documents the Windows backend and validates the required MSVC Rust toolchain before cache builds.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CaptureSection
  participant CaptureAPI
  participant Daemon
  participant WindowsScreenCaptureInput
  participant DesktopDuplicator
  CaptureSection->>CaptureAPI: Fetch capture monitors
  CaptureAPI->>Daemon: GET /api/v1/capture/monitors
  Daemon-->>CaptureSection: Return monitor metadata
  WindowsScreenCaptureInput->>DesktopDuplicator: Open selected monitor
  DesktopDuplicator-->>WindowsScreenCaptureInput: Return processed frame
Loading

Possibly related PRs

  • hyperb1iss/hypercolor#88: Changes overlap in Windows installer HypercolorSmBus broker installation and registration behavior.

Poem

I’m a bunny with pixels to see,
DXGI hops frames merrily.
PawnIO retries, services align,
Windows colors glow and shine.
A carrot PNG lands right on time!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main Windows hardware and screen-capture work in the PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
crates/hypercolor-core/src/input/screen/windows.rs (1)

160-168: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Generation is bumped even when the config lock is poisoned.

Line 167 sits outside the if let, so a poisoned mutex makes every reconfigure call bump the generation without storing anything; the worker then rebuilds its analyzer from CaptureConfig::default() (via snapshot's poison fallback) on each call. Moving the bump inside the successful branch keeps the no-op case honest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-core/src/input/screen/windows.rs` around lines 160 - 168,
Update reconfigure so settings.generation.fetch_add is executed only after
successfully locking and updating self.settings.config, inside the if let Ok
branch. Preserve the early return when the existing configuration matches, and
do not bump the generation when the mutex is poisoned.
crates/hypercolor-windows-capture/src/duplication.rs (1)

328-334: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Silent unwrap_or_default() on CPUAccessFlags degrades into an unmappable staging texture.

If the conversion ever failed, the flags become 0, texture creation succeeds, and the failure only surfaces later as an opaque "map staging texture" error. D3D11_CPU_ACCESS_READ.0 is a positive constant, so consider making the intent explicit instead of a default fallback.

♻️ Proposed change
-                CPUAccessFlags: u32::try_from(D3D11_CPU_ACCESS_READ.0).unwrap_or_default(),
+                CPUAccessFlags: D3D11_CPU_ACCESS_READ.0.cast_unsigned(),

(or #[expect(clippy::cast_sign_loss)] D3D11_CPU_ACCESS_READ.0 as u32 if cast_unsigned is unavailable on the pinned toolchain)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-windows-capture/src/duplication.rs` around lines 328 - 334,
Update the staging_desc construction in the texture duplication flow to convert
D3D11_CPU_ACCESS_READ.0 explicitly to u32 without unwrap_or_default(),
preserving the required read CPUAccessFlags and avoiding a silent zero fallback.
Use try_from with an explicit failure strategy or the reviewed cast approach,
depending on the pinned toolchain.
crates/hypercolor-windows-capture/tests/duplication_tests.rs (1)

121-131: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This is the only test that fails rather than skips on a capture error.

duplicator_or_skip and wait_for_frame both treat runtime capture errors as "skip", but line 130 panics. A resolution change or a fullscreen app grabbing the output mid-run will red the suite on a real Windows host. Consider matching the skip policy used elsewhere.

♻️ Proposed change
-            Err(error) => panic!("repeated acquisition failed: {error}"),
+            Err(error) => {
+                eprintln!("skipping: repeated acquisition failed ({error})");
+                return;
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-windows-capture/tests/duplication_tests.rs` around lines
121 - 131, Update the Err branch in the repeated frame-acquisition loop using
duplicator.next_frame so runtime capture errors follow the same skip behavior as
duplicator_or_skip and wait_for_frame instead of panicking. Preserve successful
frame validation and the existing handling of Ok(None), and exit or skip the
test cleanly when an acquisition error occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/hypercolor-core/src/input/screen/windows.rs`:
- Around line 302-311: Replace both blocking `thread::sleep(REOPEN_BACKOFF)`
calls in the Windows capture retry paths with an interruptible wait on the
command channel, so receiving `Stop` exits immediately while the backoff still
elapses before retrying. Update the surrounding loop and channel handling to
preserve normal reopen behavior and ensure `shutdown_worker` can complete
without waiting for the full backoff.
- Around line 280-311: Update the generation-change branch in the screen capture
worker so a changed config.monitor invalidates the existing duplicator before
the session-selection match. Drop or recreate the active duplicator when the
monitor changes, while preserving the current analyzer refresh and max-width
configuration behavior; this ensures DesktopDuplicator::new uses the newly
selected monitor immediately.

In `@crates/hypercolor-daemon/src/startup/services.rs`:
- Line 695: Update WindowsScreenCaptureInput::run_worker to detect changes to
config.monitor and drop/recreate the existing DesktopDuplicator when the
selected monitor changes, alongside the generation-based refresh logic. Ensure
subsequent capture samples use the newly selected display while preserving
existing analyzer and max-width updates.

In `@crates/hypercolor-windows-capture/Cargo.toml`:
- Around line 11-12: Remove the unsafe_code = "allow" exception from the
[lints.rust] configuration in Cargo.toml, then update the DXGI/COM
implementation to compile without any unsafe Rust and ensure no unsafe_code
allowance remains anywhere in the workspace.

In `@docs/specs/14-screen-capture.md`:
- Around line 1628-1632: Update the “Subsample during readback” section in
14-screen-capture.md to describe box-filtered readback rather than
integer-stride point sampling. Align the wording with the implemented Windows
capture behavior while preserving the mapped staging-memory readback and
approximate 1280px target details.

---

Nitpick comments:
In `@crates/hypercolor-core/src/input/screen/windows.rs`:
- Around line 160-168: Update reconfigure so settings.generation.fetch_add is
executed only after successfully locking and updating self.settings.config,
inside the if let Ok branch. Preserve the early return when the existing
configuration matches, and do not bump the generation when the mutex is
poisoned.

In `@crates/hypercolor-windows-capture/src/duplication.rs`:
- Around line 328-334: Update the staging_desc construction in the texture
duplication flow to convert D3D11_CPU_ACCESS_READ.0 explicitly to u32 without
unwrap_or_default(), preserving the required read CPUAccessFlags and avoiding a
silent zero fallback. Use try_from with an explicit failure strategy or the
reviewed cast approach, depending on the pinned toolchain.

In `@crates/hypercolor-windows-capture/tests/duplication_tests.rs`:
- Around line 121-131: Update the Err branch in the repeated frame-acquisition
loop using duplicator.next_frame so runtime capture errors follow the same skip
behavior as duplicator_or_skip and wait_for_frame instead of panicking. Preserve
successful frame validation and the existing handling of Ok(None), and exit or
skip the test cleanly when an acquisition error occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b63c6e1-4d2f-44f5-bdbf-527ac8691332

📥 Commits

Reviewing files that changed from the base of the PR and between aa9833a and 1eb3d00.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • crates/hypercolor-app/installer-hooks.nsh
  • crates/hypercolor-app/tests/packaging_tests.rs
  • crates/hypercolor-core/Cargo.toml
  • crates/hypercolor-core/examples/dump_screen_canvas.rs
  • crates/hypercolor-core/src/input/evdev.rs
  • crates/hypercolor-core/src/input/screen/mod.rs
  • crates/hypercolor-core/src/input/screen/windows.rs
  • crates/hypercolor-core/src/input/sensor.rs
  • crates/hypercolor-core/tests/screen_tests.rs
  • crates/hypercolor-daemon/src/startup/services.rs
  • crates/hypercolor-types/src/config.rs
  • crates/hypercolor-types/tests/config_tests.rs
  • crates/hypercolor-windows-capture/Cargo.toml
  • crates/hypercolor-windows-capture/examples/dump_frame.rs
  • crates/hypercolor-windows-capture/src/duplication.rs
  • crates/hypercolor-windows-capture/src/lib.rs
  • crates/hypercolor-windows-capture/src/shared.rs
  • crates/hypercolor-windows-capture/src/stubs.rs
  • crates/hypercolor-windows-capture/tests/duplication_tests.rs
  • crates/hypercolor-windows-capture/tests/subsample_tests.rs
  • docs/specs/14-screen-capture.md
  • scripts/cargo-cache-build.ps1
  • scripts/install-windows-hardware-support.ps1

Comment on lines +280 to +311
let latest_generation = settings.generation.load(Ordering::Acquire);
if latest_generation != generation {
generation = latest_generation;
config = settings.snapshot();
analyzer = ScreenCaptureInput::new(config.clone());
if let Some(duplicator) = duplicator.as_mut() {
duplicator.set_max_width(CAPTURE_TARGET_WIDTH);
}
}

let session = match duplicator.as_mut() {
Some(session) => session,
None => match DesktopDuplicator::new(config.monitor, CAPTURE_TARGET_WIDTH) {
Ok(session) => {
let (width, height) = session.native_extent();
info!(
monitor = config.monitor,
width, height, "Windows screen capture online"
);
open_failure_logged = false;
duplicator.insert(session)
}
Err(error) => {
if !open_failure_logged {
log_open_failure(&error);
open_failure_logged = true;
}
thread::sleep(REOPEN_BACKOFF);
continue;
}
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Changing config.monitor never takes effect on a live session.

On a generation bump the worker refreshes config and the analyzer, but only calls set_max_width(CAPTURE_TARGET_WIDTH) — a constant, so that call is a no-op. DesktopDuplicator::new(config.monitor, …) runs only when duplicator is None, so an active session stays bound to the previously selected output until an unrelated failure or a deactivate/reactivate cycle. Given monitor selection is a headline of this PR, the duplicator should be dropped when the selected monitor changes.

🐛 Proposed fix
         let latest_generation = settings.generation.load(Ordering::Acquire);
         if latest_generation != generation {
             generation = latest_generation;
+            let previous_monitor = config.monitor;
             config = settings.snapshot();
             analyzer = ScreenCaptureInput::new(config.clone());
-            if let Some(duplicator) = duplicator.as_mut() {
-                duplicator.set_max_width(CAPTURE_TARGET_WIDTH);
+            if config.monitor != previous_monitor {
+                // Rebind on the next iteration; the session is tied to one output.
+                duplicator = None;
+            } else if let Some(duplicator) = duplicator.as_mut() {
+                duplicator.set_max_width(CAPTURE_TARGET_WIDTH);
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let latest_generation = settings.generation.load(Ordering::Acquire);
if latest_generation != generation {
generation = latest_generation;
config = settings.snapshot();
analyzer = ScreenCaptureInput::new(config.clone());
if let Some(duplicator) = duplicator.as_mut() {
duplicator.set_max_width(CAPTURE_TARGET_WIDTH);
}
}
let session = match duplicator.as_mut() {
Some(session) => session,
None => match DesktopDuplicator::new(config.monitor, CAPTURE_TARGET_WIDTH) {
Ok(session) => {
let (width, height) = session.native_extent();
info!(
monitor = config.monitor,
width, height, "Windows screen capture online"
);
open_failure_logged = false;
duplicator.insert(session)
}
Err(error) => {
if !open_failure_logged {
log_open_failure(&error);
open_failure_logged = true;
}
thread::sleep(REOPEN_BACKOFF);
continue;
}
},
};
let latest_generation = settings.generation.load(Ordering::Acquire);
if latest_generation != generation {
generation = latest_generation;
let previous_monitor = config.monitor;
config = settings.snapshot();
analyzer = ScreenCaptureInput::new(config.clone());
if config.monitor != previous_monitor {
// Rebind on the next iteration; the session is tied to one output.
duplicator = None;
} else if let Some(duplicator) = duplicator.as_mut() {
duplicator.set_max_width(CAPTURE_TARGET_WIDTH);
}
}
let session = match duplicator.as_mut() {
Some(session) => session,
None => match DesktopDuplicator::new(config.monitor, CAPTURE_TARGET_WIDTH) {
Ok(session) => {
let (width, height) = session.native_extent();
info!(
monitor = config.monitor,
width, height, "Windows screen capture online"
);
open_failure_logged = false;
duplicator.insert(session)
}
Err(error) => {
if !open_failure_logged {
log_open_failure(&error);
open_failure_logged = true;
}
thread::sleep(REOPEN_BACKOFF);
continue;
}
},
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-core/src/input/screen/windows.rs` around lines 280 - 311,
Update the generation-change branch in the screen capture worker so a changed
config.monitor invalidates the existing duplicator before the session-selection
match. Drop or recreate the active duplicator when the monitor changes, while
preserving the current analyzer refresh and max-width configuration behavior;
this ensures DesktopDuplicator::new uses the newly selected monitor immediately.

Comment on lines +302 to +311
Err(error) => {
if !open_failure_logged {
log_open_failure(&error);
open_failure_logged = true;
}
thread::sleep(REOPEN_BACKOFF);
continue;
}
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blocking thread::sleep(REOPEN_BACKOFF) delays stop()/drop() by up to 2 seconds.

Neither sleep observes cancel or the command channel, so shutdown_worker (and therefore Drop for WindowsScreenCaptureInput) can block the caller for the full backoff — and it is reached on every iteration while another application holds the duplication interface, which is the documented common case. Wait on the channel instead so a Stop interrupts the backoff.

🐛 Proposed fix
+/// Sleep for `backoff`, returning early when a stop is requested.
+fn backoff_or_stop(command_rx: &mpsc::Receiver<WorkerCommand>, active: &mut bool) -> ControlFlow {
+    match command_rx.recv_timeout(REOPEN_BACKOFF) {
+        Ok(WorkerCommand::SetActive(next)) => {
+            *active = next;
+            ControlFlow::Continue
+        }
+        Ok(WorkerCommand::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => ControlFlow::Stop,
+        Err(mpsc::RecvTimeoutError::Timeout) => ControlFlow::Continue,
+    }
+}

Then at both call sites:

-                    thread::sleep(REOPEN_BACKOFF);
-                    continue;
+                    match backoff_or_stop(command_rx, &mut active) {
+                        ControlFlow::Stop => break,
+                        ControlFlow::Continue => continue,
+                    }
                 duplicator = None;
-                thread::sleep(REOPEN_BACKOFF);
+                if matches!(backoff_or_stop(command_rx, &mut active), ControlFlow::Stop) {
+                    break;
+                }

Also applies to: 324-329

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-core/src/input/screen/windows.rs` around lines 302 - 311,
Replace both blocking `thread::sleep(REOPEN_BACKOFF)` calls in the Windows
capture retry paths with an interruptible wait on the command channel, so
receiving `Stop` exits immediately while the backoff still elapses before
retrying. Update the surrounding loop and channel handling to preserve normal
reopen behavior and ensure `shutdown_worker` can complete without waiting for
the full backoff.

}
.clamped(),
restore_token: capture.restore_token.clone(),
monitor: hypercolor_core::input::screen::monitor_index_from_source(&capture.source),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reopen the Windows session when the selected monitor changes.

Line 695 feeds monitor into the live worker configuration, but WindowsScreenCaptureInput::run_worker only refreshes the analyzer and max width when a generation changes; it retains the existing DesktopDuplicator. Changing capture.source while capture is active therefore continues sampling the old display. Drop and recreate the duplicator when config.monitor changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-daemon/src/startup/services.rs` at line 695, Update
WindowsScreenCaptureInput::run_worker to detect changes to config.monitor and
drop/recreate the existing DesktopDuplicator when the selected monitor changes,
alongside the generation-based refresh logic. Ensure subsequent capture samples
use the newly selected display while preserving existing analyzer and max-width
updates.

Comment on lines +11 to +12
[lints.rust]
unsafe_code = "allow"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Enforce the workspace prohibition on unsafe Rust.

Line 12 explicitly sets unsafe_code = "allow", permitting the DXGI/COM implementation to contain unsafe code even though the repository rule forbids it everywhere. Remove this exception and resolve the implementation accordingly before merging.

As per coding guidelines, **/*.rs: Do not use unsafe_code anywhere in the workspace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-windows-capture/Cargo.toml` around lines 11 - 12, Remove
the unsafe_code = "allow" exception from the [lints.rust] configuration in
Cargo.toml, then update the DXGI/COM implementation to compile without any
unsafe Rust and ensure no unsafe_code allowance remains anywhere in the
workspace.

Source: Coding guidelines

Comment on lines +1628 to +1632
- **Subsample during readback.** Frames are point-sampled by an integer stride
to roughly 1280px wide while being copied out of mapped staging memory,
which matches what PipeWire is asked to deliver on Linux. Sampling after a
full-resolution copy would move 33 MB per frame on a 4K desktop for a result
the sector grid cannot distinguish.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document box-filtered readback, not point sampling.

The PR objective states that Windows capture uses box-filtered readback, but this section describes integer-stride point sampling. Update the wording to match the implemented filtering behavior so the specification does not mislead future implementations.

Suggested wording
-Frames are point-sampled by an integer stride
+Frames are box-filtered over an integer stride
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **Subsample during readback.** Frames are point-sampled by an integer stride
to roughly 1280px wide while being copied out of mapped staging memory,
which matches what PipeWire is asked to deliver on Linux. Sampling after a
full-resolution copy would move 33 MB per frame on a 4K desktop for a result
the sector grid cannot distinguish.
- **Subsample during readback.** Frames are box-filtered over an integer stride
to roughly 1280px wide while being copied out of mapped staging memory,
which matches what PipeWire is asked to deliver on Linux. Sampling after a
full-resolution copy would move 33 MB per frame on a 4K desktop for a result
the sector grid cannot distinguish.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/specs/14-screen-capture.md` around lines 1628 - 1632, Update the
“Subsample during readback” section in 14-screen-capture.md to describe
box-filtered readback rather than integer-stride point sampling. Align the
wording with the implemented Windows capture behavior while preserving the
mapped staging-memory readback and approximate 1280px target details.

hyperb1iss and others added 4 commits July 25, 2026 18:58
Ambilight rendered garbage from a real desktop because the letterbox
detector reported bars from all four edges at once. Measured against a
live 3840x2160 capture, an 8x6 grid came back as top=6 bottom=6 left=8
right=8: every row and every column classified as a black bar, so
cropping removed the entire picture, and because the verdict moves with
ordinary content changes it strobed frame to frame.

The cause is that pixel_luminance is linear while the threshold reads
like an sRGB one. Linear luminance punishes dark content brutally: sRGB
30/255 is 0.013 linear, well under the 0.02 default. A dark-themed
desktop is therefore "black" from every edge, which is exactly the
content ambient lighting is most often pointed at.

Two changes. Detection now discards an axis whose bars would swallow the
whole grid, because real letterboxing always leaves something in the
middle and a uniformly dark frame is dark rather than letterboxed. And
letterbox defaults to off, since ambient lighting almost always mirrors
a desktop rather than a letterboxed film; the guard alone still left two
of eight columns cropped on the same desktop, quietly discarding a
quarter of the screen.

An existing test asserted the old degenerate path, where an all-black
frame produced bars that consumed everything. That premise is gone, so
it now asserts no bars are reported; crop_letterbox's own refusal to
leave nothing behind is still covered, driven by explicit bars rather
than by detection.

Verified on the same live desktop: bars went from [6,6,8,8] to
[0,0,0,2], and to none with the default off.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
CI runs cargo with --locked, so the image dev-dependency added to
hypercolor-windows-capture for the frame-dump examples failed every Rust
job in seconds with "cannot update the lock file". Local builds hid it by
updating the lock in place and leaving it uncommitted.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Selecting a bundled preset never stuck. The dropdown snapped back to
whatever was already applied, so Screen Cast in particular was pinned to
whichever preset its layer happened to be created with — Center Zoom,
whose 40% viewport crop is why the capture looked like a blurry mess.

Saved presets and bundled presets took different routes. Saved presets
go through apply_preset, which targets the focused zone. Bundled presets
went through update_controls, which PATCHes the legacy global
/effects/current/controls. There is no legacy "current effect" in a zone
scene, so that 404s, and the error branch restores the previous
selection — the snap-back was the failure handler doing its job on a
request that could never succeed.

Send them the same place a manual control edit already goes: the zone's
synthetic legacy layer, whose group and layer ids are both the zone id.
The legacy endpoint stays as the fallback for when no zone scene is
active, which is the only case it was ever right for.

Verified: cargo check and clippy -D warnings clean for the wasm target.
Not exercised in a browser — the failing request is gone by
construction, but the applied-preset round trip still wants a manual
pass.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The published capture surface targeted the canvas bounds directly, so a
16:9 desktop was resampled into 640x480 — 2x horizontally but 1.5x
vertically, a 1.33x vertical stretch. No downstream fit mode can undo
that, because the distortion is baked into the pixels before any effect
sees them; circles reached screen-mirroring effects as ellipses.

Fit within the canvas bounds instead of filling them. A 4K or 1080p
desktop now publishes 640x360, a square source 480x480, and portrait or
ultrawide sources scale on whichever axis binds first.

An existing test asserted a 40x40 frame produced a 640x480 surface,
which was the squash itself written down as an expectation; a square
source now yields a square surface.

Also aligns the core CaptureConfig letterbox default with the one in
config::CaptureConfig, which was flipped off in the previous commit.
Leaving them disagreeing meant a caller building the core config
directly still got the old cropping behaviour.

Verified against a live 3840x2160 desktop: canvas_downscale went from
640x480 to 640x360.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/hypercolor-ui/src/components/preset_panel.rs`:
- Around line 249-270: Update the scoped branch of the bundled-preset PATCH flow
inside the async task to handle the returned LayerStackOutcome and refresh or
apply it to ZonesContext after a successful patch. Mirror the existing
zone-scoped success path, while preserving the global update_controls behavior
when scene_scoped_target() returns None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fcbddcec-70a8-46ae-b733-5f44928484de

📥 Commits

Reviewing files that changed from the base of the PR and between 6a75ebd and 3bacb0f.

📒 Files selected for processing (2)
  • crates/hypercolor-ui/src/components/preset_panel.rs
  • crates/hypercolor-ui/src/zones.rs

Comment on lines +249 to +270
// Bundled presets write the same controls a manual edit
// does, so they must take the same route: the zone's
// synthetic legacy layer, keyed by the zone id. The old
// global endpoint 404s whenever no legacy "current
// effect" exists, which is always in a zone scene — and
// the failure branch below then snapped the dropdown
// back, so bundled presets looked like they refused to
// stick.
let scoped = zones_ctx.scene_scoped_target();
leptos::task::spawn_local(async move {
match api::update_controls(&controls_json).await {
let result = match scoped {
Some((scene_id, zone_id)) => api::patch_layer_controls(
&scene_id,
&zone_id,
&zone_id,
&controls_json,
None,
)
.await
.map(|_| ()),
None => api::update_controls(&controls_json).await,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Refresh the active scene after a scoped bundled-preset update.

When scene_scoped_target() returns Some, this branch mutates the zone layer but never refreshes ZonesContext or applies the returned LayerStackOutcome. The UI can therefore retain stale zone controls and later overwrite or display state older than the preset just applied. Mirror the existing zone-scoped success path by refreshing the scene after a successful PATCH, or consume the response to update the cache.

Proposed fix
                     let result = match scoped {
-                        Some((scene_id, zone_id)) => api::patch_layer_controls(
-                            &scene_id,
-                            &zone_id,
-                            &zone_id,
-                            &controls_json,
-                            None,
-                        )
-                        .await
-                        .map(|_| ()),
+                        Some((scene_id, zone_id)) => {
+                            let result = api::patch_layer_controls(
+                                &scene_id,
+                                &zone_id,
+                                &zone_id,
+                                &controls_json,
+                                None,
+                            )
+                            .await
+                            .map(|_| ());
+                            if result.is_ok() {
+                                zones_ctx.refresh.run(());
+                            }
+                            result
+                        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Bundled presets write the same controls a manual edit
// does, so they must take the same route: the zone's
// synthetic legacy layer, keyed by the zone id. The old
// global endpoint 404s whenever no legacy "current
// effect" exists, which is always in a zone scene — and
// the failure branch below then snapped the dropdown
// back, so bundled presets looked like they refused to
// stick.
let scoped = zones_ctx.scene_scoped_target();
leptos::task::spawn_local(async move {
match api::update_controls(&controls_json).await {
let result = match scoped {
Some((scene_id, zone_id)) => api::patch_layer_controls(
&scene_id,
&zone_id,
&zone_id,
&controls_json,
None,
)
.await
.map(|_| ()),
None => api::update_controls(&controls_json).await,
};
// Bundled presets write the same controls a manual edit
// does, so they must take the same route: the zone's
// synthetic legacy layer, keyed by the zone id. The old
// global endpoint 404s whenever no legacy "current
// effect" exists, which is always in a zone scene — and
// the failure branch below then snapped the dropdown
// back, so bundled presets looked like they refused to
// stick.
let scoped = zones_ctx.scene_scoped_target();
leptos::task::spawn_local(async move {
let result = match scoped {
Some((scene_id, zone_id)) => {
let result = api::patch_layer_controls(
&scene_id,
&zone_id,
&zone_id,
&controls_json,
None,
)
.await
.map(|_| ());
if result.is_ok() {
zones_ctx.refresh.run(());
}
result
}
None => api::update_controls(&controls_json).await,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-ui/src/components/preset_panel.rs` around lines 249 - 270,
Update the scoped branch of the bundled-preset PATCH flow inside the async task
to handle the returned LayerStackOutcome and refresh or apply it to ZonesContext
after a successful patch. Mirror the existing zone-scoped success path, while
preserving the global update_controls behavior when scene_scoped_target()
returns None.

hyperb1iss and others added 2 commits July 25, 2026 19:29
The Settings "Source" dropdown offered "Auto" and "PipeWire" — backend
implementation names, one of them Linux-only, shown to Windows users as
the only way to aim screen capture. The real selector, capture.source =
"monitor:N", was reachable only by hand-editing config, with nothing
telling the user what N meant on their machine.

The capture crate now describes every attached output — capture index,
OS device name, desktop extent, and whether it anchors the virtual
desktop origin — and GET /api/v1/capture/monitors serves the list with
a ready-to-store capture.source value per entry. An empty list is the
capability signal: portal platforms return nothing, and the UI keeps
the portal picker there instead of a dropdown.

Index order matches DesktopDuplicator's adapter-then-output
enumeration, so what the picker shows is exactly what a duplicator
opens.

Verified live against real hardware: two outputs reported with correct
geometry, including a portrait 1440x2560 secondary, and the listing
test asserts the count matches, extents are nonzero, and a primary
exists.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The Screen Capture section was thirteen rows of raw engine knobs —
scene-cut thresholds, letterbox luminance floors, sector grid dimensions
— presented flat, with a Source dropdown whose entries were backend
implementation names ("PipeWire" on a Windows machine). Users met video
pipeline internals before they could answer the only two questions that
matter: is capture on, and which screen.

The section is now those two decisions. The dropdown lists real
monitors from /api/v1/capture/monitors with resolution and a primary
tag; on portal platforms the list is empty and the portal picker button
renders in its place, so each platform shows only the selector it
actually honours.

Everything else moves behind a collapsed Advanced disclosure with
descriptions written for humans: smoothing becomes "Color response",
the letterbox toggle says it is for video and warns that dark desktop
scenes can be mistaken for bars, and the sampling grid rows say
outright that layouts reference the zones so changing them remaps
things. Nothing was removed and no keys changed — everything is still
there for the users it exists for, in the place that says how often
that is.

Verified: cargo check and clippy -D warnings clean for the wasm target;
daemon api and openapi tests pass (192 + 3).

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/hypercolor-windows-capture/src/shared.rs (1)

107-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the discarded describe_outputs() error.

unwrap_or_default() erases a real DXGI failure into the same empty vector that means "no picker on this platform", so a Windows enumeration failure is indistinguishable from Linux and leaves no trace for diagnosis.

♻️ Proposed change
     #[cfg(target_os = "windows")]
     {
-        crate::duplication::describe_outputs().unwrap_or_default()
+        match crate::duplication::describe_outputs() {
+            Ok(monitors) => monitors,
+            Err(error) => {
+                tracing::warn!(%error, "Failed to enumerate DXGI outputs");
+                Vec::new()
+            }
+        }
     }

As per coding guidelines, "Use tracing for logging and never println! in library code."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-windows-capture/src/shared.rs` around lines 107 - 116,
Update list_monitors to handle the Err case from
crate::duplication::describe_outputs() explicitly instead of using
unwrap_or_default(), and log the discarded DXGI error through the library’s
tracing facility. Preserve the empty-vector result for failures and non-Windows
builds.

Source: Coding guidelines

crates/hypercolor-ui/src/components/settings_sections.rs (2)

234-247: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add aria-expanded to the disclosure button.

The chevron and label change convey state visually only; screen readers get no expanded/collapsed cue.

♻️ Proposed change
             <button
                 type="button"
+                aria-expanded=move || show_advanced.get().to_string()
                 class="flex w-full items-center gap-1.5 py-3 text-left text-[12px] font-medium text-fg-tertiary hover:text-fg-secondary transition-colors"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-ui/src/components/settings_sections.rs` around lines 234 -
247, Add an aria-expanded attribute to the disclosure button that is bound to
show_advanced, returning the current boolean state so assistive technologies
receive the expanded/collapsed status while preserving the existing toggle
behavior.

149-156: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Monitor list is fetched once and fetch errors are indistinguishable from "no picker".

LocalResource never refetches, so a monitor hot-plug or a daemon reconnect leaves a stale list, and a failed request collapses to an empty vec — which on Windows renders the portal "Choose source" button that does nothing there. Key the resource on connection_generation and log/surface the error case separately.

As per coding guidelines, "Do not rely on browser polling in the UI; use WebSocket events, hint signals, and connection_generation-based refetching instead."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-ui/src/components/settings_sections.rs` around lines 149 -
156, The monitor resource in the settings component currently fetches only once
and converts fetch failures into an empty list. Key `LocalResource::new` to the
existing `connection_generation` signal so reconnects and monitor changes
trigger refetching, and handle/log the `Err` case separately before deriving
`has_monitor_picker`, ensuring failures do not appear as a valid empty monitor
list.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/hypercolor-ui/src/components/settings_sections.rs`:
- Line 158: Update the option initialization in the settings source-selection
flow so the "auto" entry describes the actual capture-index-0 behavior rather
than labeling it as the primary monitor; keep the source value "auto" unchanged
and use wording consistent with adapter/output enumeration.

---

Nitpick comments:
In `@crates/hypercolor-ui/src/components/settings_sections.rs`:
- Around line 234-247: Add an aria-expanded attribute to the disclosure button
that is bound to show_advanced, returning the current boolean state so assistive
technologies receive the expanded/collapsed status while preserving the existing
toggle behavior.
- Around line 149-156: The monitor resource in the settings component currently
fetches only once and converts fetch failures into an empty list. Key
`LocalResource::new` to the existing `connection_generation` signal so
reconnects and monitor changes trigger refetching, and handle/log the `Err` case
separately before deriving `has_monitor_picker`, ensuring failures do not appear
as a valid empty monitor list.

In `@crates/hypercolor-windows-capture/src/shared.rs`:
- Around line 107-116: Update list_monitors to handle the Err case from
crate::duplication::describe_outputs() explicitly instead of using
unwrap_or_default(), and log the discarded DXGI error through the library’s
tracing facility. Preserve the empty-vector result for failures and non-Windows
builds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 231855c4-83cf-41f5-80f1-cde2aa9c02c3

📥 Commits

Reviewing files that changed from the base of the PR and between 394f191 and e66fe9a.

📒 Files selected for processing (10)
  • crates/hypercolor-core/src/input/screen/mod.rs
  • crates/hypercolor-daemon/src/api/capture.rs
  • crates/hypercolor-daemon/src/api/mod.rs
  • crates/hypercolor-ui/src/api/config.rs
  • crates/hypercolor-ui/src/components/settings_sections.rs
  • crates/hypercolor-windows-capture/Cargo.toml
  • crates/hypercolor-windows-capture/src/duplication.rs
  • crates/hypercolor-windows-capture/src/lib.rs
  • crates/hypercolor-windows-capture/src/shared.rs
  • crates/hypercolor-windows-capture/tests/duplication_tests.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/hypercolor-windows-capture/Cargo.toml
  • crates/hypercolor-windows-capture/src/lib.rs
  • crates/hypercolor-windows-capture/tests/duplication_tests.rs
  • crates/hypercolor-windows-capture/src/duplication.rs

});
let has_monitor_picker = Signal::derive(move || !monitors.get().is_empty());
let monitor_options = Signal::derive(move || {
let mut options = vec![("auto".to_owned(), "Primary monitor".to_owned())];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"auto" is not guaranteed to be the primary monitor.

capture.source = "auto" resolves to capture index 0 in monitor_index_from_source, but index 0 is adapter-then-output enumeration order while primary is derived from the desktop origin in describe_outputs. On multi-GPU or rearranged desktops these differ, so the label misdescribes what will be captured.

♻️ Proposed change
-        let mut options = vec![("auto".to_owned(), "Primary monitor".to_owned())];
+        let mut options = vec![("auto".to_owned(), "Default monitor".to_owned())];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut options = vec![("auto".to_owned(), "Primary monitor".to_owned())];
let mut options = vec![("auto".to_owned(), "Default monitor".to_owned())];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hypercolor-ui/src/components/settings_sections.rs` at line 158, Update
the option initialization in the settings source-selection flow so the "auto"
entry describes the actual capture-index-0 behavior rather than labeling it as
the primary monitor; keep the source value "auto" unchanged and use wording
consistent with adapter/output enumeration.

CI's Linux shared check failed on dead code: CaptureError::windows() is
pub(crate) and its only callers live in the cfg(windows) duplication
module, so on Linux the constructor compiles with no callers and the
workspace's -D warnings turns that into a build failure. Windows clippy
can never surface this, the mirror image of the evdev lint that kept
main red for a week.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
@hyperb1iss hyperb1iss changed the title fix(windows): repair the first-run hardware stack and add screen capture fix(windows): repair first-run hardware access, land screen capture, fix the capture pipeline Jul 26, 2026
@hyperb1iss
hyperb1iss merged commit 85b0620 into main Jul 26, 2026
18 checks passed
@hyperb1iss
hyperb1iss deleted the fix/windows-first-run-hardware-access branch July 26, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant