From d945579ddfee426393f3d878bc1127ec4bcb240b Mon Sep 17 00:00:00 2001 From: mskutin <11622907+mskutin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:59:52 +0800 Subject: [PATCH 1/2] fix: initialize Windows capture runtime before audio --- docs/capture-sources.md | 2 +- src-tauri/src/capture_windows.rs | 30 ++++++++++++++++------ src-tauri/src/engine.rs | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/capture-sources.md b/docs/capture-sources.md index ee6fcc3..cb80564 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -16,4 +16,4 @@ Rectangle coordinates are normalized to the selected display and rounded out to Protected video, DRM, secure desktops, permission restrictions, and unsupported GPU paths can yield black or unavailable frames. The application does not bypass these protections. Captured pixels are sampled locally; only RGB lighting colors reach USB. Window titles and source IDs are excluded from diagnostic exports and the automation status API. -Native validation includes rectangle rounding at multiple resolutions, source-change consent revocation, and an interactive Windows test that captures only its own generated fixture window, resizes it, then verifies capture terminates on closure. No USB is used by that test. Physical multi-DPI, protected video, macOS and Linux window behavior remain in the platform validation trackers. +Native validation includes rectangle rounding at multiple resolutions, source-change consent revocation, and an interactive Windows test that queries the audio device before repeatedly starting/stopping capture of its own generated fixture window, resizes it, then verifies capture terminates on closure. No USB or audio recording is used by that test. Each Windows lighting worker initializes the multithreaded Windows Runtime before audio discovery and retains it across capture sessions, preventing the audio library's default single-threaded COM initialization from blocking later window capture with `0x80010106`. A separate regression test checks that worker lifetime without opening capture or audio devices. Physical multi-DPI, protected video, macOS and Linux window behavior remain in the platform validation trackers. diff --git a/src-tauri/src/capture_windows.rs b/src-tauri/src/capture_windows.rs index d5f3579..2242c07 100644 --- a/src-tauri/src/capture_windows.rs +++ b/src-tauri/src/capture_windows.rs @@ -294,7 +294,15 @@ use windows::Win32::System::WinRT::{ Graphics::Capture::IGraphicsCaptureItemInterop, RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED, }; -struct Apartment; +// Initialization and teardown must stay on the same thread. The worker keeps +// one guard for its whole lifetime; capture sessions take an additional lease. +pub(crate) struct Apartment(std::marker::PhantomData<*mut ()>); +impl Apartment { + pub(crate) fn new() -> windows::core::Result { + unsafe { RoInitialize(RO_INIT_MULTITHREADED)? }; + Ok(Self(std::marker::PhantomData)) + } +} impl Drop for Apartment { fn drop(&mut self) { unsafe { RoUninitialize() }; @@ -326,15 +334,10 @@ pub struct WindowCapture { } impl WindowCapture { pub fn new(id: u32) -> Result { - Self::create(id).map_err(|e| { - format!("Window capture unavailable: {e}. Windows 10 1903 or newer is required.") - }) + Self::create(id).map_err(|e| format!("Window capture unavailable: {e}")) } fn create(id: u32) -> windows::core::Result { - unsafe { - RoInitialize(RO_INIT_MULTITHREADED)?; - } - let apartment = Apartment; + let apartment = Apartment::new()?; let (mut device, mut context) = (None, None); unsafe { D3D11CreateDevice( @@ -551,6 +554,11 @@ mod window_tests { #[test] #[ignore = "requires an interactive Windows desktop with Windows Graphics Capture"] fn selected_window_capture_resizes_and_closes_without_desktop_fallback() { + // Match the lighting worker: WinRT must be initialized before WASAPI. + // Query devices only; do not open an audio stream or record any audio. + let _apartment = Apartment::new().unwrap(); + use cpal::traits::HostTrait; + drop(cpal::default_host().default_output_device()); let stop = Arc::new(AtomicBool::new(false)); let done = stop.clone(); let (tx, rx) = mpsc::channel(); @@ -588,6 +596,12 @@ mod window_tests { let hwnd = windows::Win32::Foundation::HWND( rx.recv_timeout(Duration::from_secs(5)).unwrap() as *mut c_void, ); + // Repeated start/stop must not tear down COM underneath CPAL's cached + // initialization. Exercise the Music -> Window -> Music -> Window order. + for _ in 0..2 { + drop(WindowCapture::new(hwnd.0 as usize as u32).unwrap()); + drop(cpal::default_host().default_output_device()); + } let mut capture = WindowCapture::new(hwnd.0 as usize as u32).unwrap(); let settings = Settings::default(); let mut frame = vec![]; diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index fdf89d1..af7a0b9 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -1702,6 +1702,13 @@ pub fn start( thread::Builder::new() .name("lighting".into()) .spawn(move || { + // WASAPI otherwise initializes this thread as STA first, making + // later WinRT/MTA window capture fail with RPC_E_CHANGED_MODE. + // Keep COM alive across every screen/audio start/stop, and drop it + // after the engine and its capture/audio objects have been dropped. + #[cfg(target_os = "windows")] + let _apartment = crate::capture_windows::Apartment::new() + .expect("initialize lighting worker Windows Runtime"); let mut engine = Engine::new(settings, path, warning); engine.restore(); let mut clock = crate::platform::FrameClock::new(); @@ -1783,6 +1790,42 @@ fn missed_slots(late: Duration, period: Duration) -> u64 { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "windows")] + #[test] + fn windows_worker_keeps_mta_before_and_between_capture_sessions() { + use windows::Win32::{ + Foundation::RPC_E_CHANGED_MODE, + System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_SINGLETHREADED}, + }; + let dir = tempfile::tempdir().unwrap(); + let (tx, rx) = mpsc::channel(); + let worker = start( + Settings::default(), + dir.path().join("settings.json"), + String::new(), + move |_| { + // Model WASAPI's attempted STA initialization without opening an + // audio device. It must find the worker's existing MTA each time. + let attempt = unsafe { RoInitialize(RO_INIT_SINGLETHREADED) }; + if attempt.is_ok() { + unsafe { RoUninitialize() }; + } + let mta = attempt.is_err_and(|e| e.code() == RPC_E_CHANGED_MODE); + let capture_lease = crate::capture_windows::Apartment::new(); + let capture_ready = capture_lease.is_ok(); + drop(capture_lease); + let _ = tx.send(mta && capture_ready); + }, + ); + let mut observations = Vec::new(); + for _ in 0..3 { + worker.request(Action::Snapshot).unwrap(); + observations.push(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + } + worker.request(Action::Shutdown).unwrap(); + assert!(observations.into_iter().all(|mta| mta)); + } + #[test] fn controllers_keep_independent_settings_and_group_gain_without_overwriting_levels() { let dir = tempfile::tempdir().unwrap(); From 2ba8113033955e5ff9921413fb6860848c270986 Mon Sep 17 00:00:00 2001 From: mskutin <11622907+mskutin@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:08:14 +0800 Subject: [PATCH 2/2] release: prepare window capture fix for 0.16.1 --- control/Cargo.lock | 2 +- control/Cargo.toml | 2 +- docs/release-notes.md | 19 +++++++++---------- package.json | 2 +- src-tauri/Cargo.lock | 4 ++-- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/control/Cargo.lock b/control/Cargo.lock index 8398abd..a6029d1 100644 --- a/control/Cargo.lock +++ b/control/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "0.16.0" +version = "0.16.1" dependencies = [ "dirs", "getrandom 0.3.4", diff --git a/control/Cargo.toml b/control/Cargo.toml index 4db97d4..6fbdd51 100644 --- a/control/Cargo.toml +++ b/control/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight-control" -version = "0.16.0" +version = "0.16.1" edition = "2021" license = "MIT" rust-version = "1.99" diff --git a/docs/release-notes.md b/docs/release-notes.md index c7ce19e..6a1c3db 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,14 +1,13 @@ -DynamicLight 0.10.0 adds an opt-in authenticated local API and a standalone CLI for Windows, macOS, and Linux. Enable Settings → Local automation, then use `dynamiclight status`, `power on|off|toggle`, `brightness 35`, `scenes list|apply`, or `profiles list|apply`. +DynamicLight 0.16.1 fixes Windows selected-window capture failing with “Cannot change thread mode after it is set” (0x80010106) after audio initialization. -The API binds only to localhost, uses a private rotating credential, and validates and rate-limits requests. Capture starts require both the Settings permission and explicit command consent. Commands use the existing lighting engine, preserving comfort limits, lifecycle rules, and capture permission checks. +Each lighting worker now initializes the Windows Runtime before audio discovery and keeps it initialized across capture sessions. Switching between Music, Screen, and Blend no longer creates that thread-mode conflict. Unrelated capture errors also no longer incorrectly suggest that your Windows version is too old. -| Platform | Desktop asset | CLI asset | -| --- | --- | --- | -| Windows x64 | `dynamiclight-desktop-windows-x64-setup.exe` | `dynamiclight-cli-windows-x64.exe` | -| macOS Apple silicon | `dynamiclight-desktop-macos-arm64.dmg` | `dynamiclight-cli-macos-arm64` | -| macOS Intel | `dynamiclight-desktop-macos-x64.dmg` | `dynamiclight-cli-macos-x64` | -| Linux x64 (Ubuntu 24.04+) | `dynamiclight-desktop-linux-x64.deb` or `.AppImage` | `dynamiclight-cli-linux-x64` | +Validation includes 100 passing native tests, a regression test verified to fail before the fix, and a real Windows capture fixture covering audio-device discovery, repeated capture startup/teardown, resizing, and window closure. The fixture records only its own generated window; it does not use USB or record audio. -Terminal installers install the desktop by default. Use `bash install.sh --cli` or `install.ps1 -Cli` for the CLI. `SHA256SUMS` covers all assets. The CLI requires the desktop app to remain running and is updated separately by rerunning its installer. See the [installation guide](https://github.com/mskutin/DynamicLight/blob/main/docs/installation.md) and [automation guide](https://github.com/mskutin/DynamicLight/blob/main/docs/automation.md) for shell, Stream Deck, and home-automation examples. +All features from 0.16.0 remain available, including Studio effects and zones, RGB recording/replay, routines, independent controllers, game telemetry, declarative plugins, the CLI, and paired HTTPS phone controls. -Existing 0.9.0 installations can use Settings → App updates. Earlier versions need a manual installation once. Desktop updater packages remain cryptographically signed. OS publisher signing/notarization is still pending; these are preview builds. macOS requires 13+, and Linux screen sync requires X11. Physical Mac/Linux hardware and Stream Deck validation remain pending. Installation does not start lighting or capture. +Use **Settings → App updates → Check for updates → Install and restart** on Windows, writable macOS app installations, and Linux AppImage. Debian packages use the terminal installer or package manager. After restarting, select the intended window again and explicitly start capture. Windows selected-window capture requires Windows 10 1903 or newer. + +Desktop and companion CLI downloads are available for Windows x64, macOS Apple silicon/Intel, and Linux x64. See the [installation guide](https://github.com/mskutin/DynamicLight/blob/main/docs/installation.md). Desktop updates do not update the separately installed CLI. + +Updater packages are cryptographically signed; Windows publisher signing and macOS notarization remain pending. Physical hardware, mobile browser, and accessibility validation remain tracked separately from automated checks. diff --git a/package.json b/package.json index 57a9665..c5d13f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dynamiclight", - "version": "0.16.0", + "version": "0.16.1", "private": true, "license": "MIT", "author": "mskutin", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9a713ec..d46de36 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1227,7 +1227,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dynamiclight" -version = "0.16.0" +version = "0.16.1" dependencies = [ "cc", "chrono", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "0.16.0" +version = "0.16.1" dependencies = [ "dirs 6.0.0", "getrandom 0.3.4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72f349c..7229387 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight" -version = "0.16.0" +version = "0.16.1" description = "Local USB monitor backlight controller" authors = ["mskutin"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3bcdfc4..32d7260 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DynamicLight", - "version": "0.16.0", + "version": "0.16.1", "identifier": "com.dynamiclight.desktop", "build": { "frontendDist": "../src/ui"