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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion control/Cargo.lock

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

2 changes: 1 addition & 1 deletion control/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dynamiclight-control"
version = "0.16.0"
version = "0.16.1"
edition = "2021"
license = "MIT"
rust-version = "1.99"
Expand Down
2 changes: 1 addition & 1 deletion docs/capture-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 9 additions & 10 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dynamiclight",
"version": "0.16.0",
"version": "0.16.1",
"private": true,
"license": "MIT",
"author": "mskutin",
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
30 changes: 22 additions & 8 deletions src-tauri/src/capture_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
unsafe { RoInitialize(RO_INIT_MULTITHREADED)? };
Ok(Self(std::marker::PhantomData))
}
}
impl Drop for Apartment {
fn drop(&mut self) {
unsafe { RoUninitialize() };
Expand Down Expand Up @@ -326,15 +334,10 @@ pub struct WindowCapture {
}
impl WindowCapture {
pub fn new(id: u32) -> Result<Self, String> {
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<Self> {
unsafe {
RoInitialize(RO_INIT_MULTITHREADED)?;
}
let apartment = Apartment;
let apartment = Apartment::new()?;
let (mut device, mut context) = (None, None);
unsafe {
D3D11CreateDevice(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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![];
Expand Down
43 changes: 43 additions & 0 deletions src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading