diff --git a/CLAUDE.md b/CLAUDE.md index 296f1cf..e85d9ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ - Захват экрана (`capture.rs`): при потере DXGI duplication (UAC, блокировка, смена режима) держим последний кадр, пересоздаём захват 4 раза/с, через 0,3 с приглушаем до 25 %. - Lock-клавиши: хук + `hold_output` (см. ниже). - Автонастройка без участия пользователя: при подключении `0x82` даёт число LED и диагональ; `lighting::guess_sides` выбирает 3 или 4 стороны по шагу LED ≈2 см (0.5 LED/см), соотношение сторон — из реального монитора (`capture::display_aspect`). Киты: 24"/27", 3- и 4-сторонние (24": ~56/~83 LED, 27": ~64/~94; порог 69/78). Ручной выбор сторон не перетирается. В UI предупреждение, если сумма сторон ≠ числу LED. -- Windows: Launch at login = задача Планировщика с HighestAvailable (один UAC при переключении), старый Run-ключ удаляется; `ChangeWindowMessageFilter(WM_COPYDATA)` чтобы обычный запуск ярлыка передавал управление админской копии. +- Windows: Launch at login по умолчанию = Run-ключ (tauri-plugin-autostart, `--autostart`, без UAC); при включённой опции «Run with administrator rights» — задача Планировщика с HighestAvailable (один UAC при переключении), Run-ключ при этом удаляется. `ChangeWindowMessageFilter(WM_COPYDATA)` чтобы обычный запуск ярлыка передавал управление админской копии. - Тёмные сцены (`lighting::lift_dark`, настройка `dark_scenes` 0–100, по умолчанию 35, слайдер в Screen): ниже 40 % яркости цвет масштабируется по кривой с одним множителем на все каналы — оттенок сохраняется, тусклые цвета не гаснут и не уходят в «грязь»; яркие и чёрный не трогаются. (SyncLight «lowLight» на деле — ограничение суммы R+G+B ≤ 255, т.е. тока; не то же самое.) - FPS 90/120 с пометкой «experimental» (`settings.fps`), следить за Write failures. Без прав администратора (Windows) частота ограничена 30 (`device::effective_fps`/`fps_limit`): выше хук не видит админские окна и Caps Lock даёт вспышки. В UI варианты >30 отключены, под выбором предупреждение и кнопка «Run with administrator rights». - Права администратора — опция (ветка `feature/admin-rights-option`, решено 03.11.2026): Settings → Startup → «Run with administrator rights», по умолчанию выкл. Команда `admin_rights` (`autostart_windows::admin_mode` = есть launch-задача; `enable_admin` — один UAC, задачи, перезапуск через `--relaunch-after`; `disable_admin` — удаляет задачи, права пропадут при следующем запуске). Недоступна обычному пользователю (`can_elevate`: TokenElevationTypeLimited). `is_elevated` — TokenElevation вместо устаревшей IsUserAnAdmin. Launch at login: без опции — Run-ключ (tauri-plugin-autostart, имя = productName, `--autostart`), с опцией — login-задача; при переключении автозагрузка переезжает. Кто уже имел задачи — опция считается включённой. Ревью: задача с HighestAvailable указывает на exe в `%LOCALAPPDATA%` (обход UAC подменой/переименованием папки) — осознанно оставлено, риск только у включивших опцию; perMachine отклонён. diff --git a/control/Cargo.lock b/control/Cargo.lock index f2efd8f..298651b 100644 --- a/control/Cargo.lock +++ b/control/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "1.1.0" +version = "1.1.1" dependencies = [ "dirs", "getrandom 0.3.4", diff --git a/control/Cargo.toml b/control/Cargo.toml index efb6e77..bb777de 100644 --- a/control/Cargo.toml +++ b/control/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight-control" -version = "1.1.0" +version = "1.1.1" edition = "2021" license = "MIT" rust-version = "1.99" diff --git a/docs/release-notes.md b/docs/release-notes.md index 5569711..32de050 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,11 +1,10 @@ -DynamicLight 1.1.0 is a release of the BrenOne fork of mskutin/DynamicLight, now for Windows, macOS and Linux. +DynamicLight 1.1.1 is a release of the BrenOne fork of mskutin/DynamicLight, now for Windows, macOS and Linux. -**New in 1.1.0** +**New in 1.1.1** -- Windows: **Run with highest rights** is now an optional setting (Settings → General → Startup), like in Wallpaper Engine. Off by default: the app starts normally with Windows through the Run key. Turn it on to get 60+ FPS and no flashes on Caps/Num/Scroll Lock (one UAC approval). The installer no longer asks about it. -- Lock-key protection is now applied per strip and only above 30 FPS; the poll idles otherwise, so it costs nothing at 30 FPS or less. -- Less CPU work when the screen capture is unavailable (UAC, lock screen): retries are slower when only the GDI fallback is active. -- Fewer allocations in the output path. +- Windows: updating the app no longer removes the start-with-Windows entry or the administrator-rights tasks (the uninstall step now leaves them alone during updates). +- Windows: switching **Run with administrator rights** is more reliable. If the old Run entry cannot be removed, or Launch at login cannot be restored, you get a clear error instead of two copies starting at sign-in; the settings always show the real state afterwards. +- Screen capture returns from the GDI fallback faster after a long lock screen or UAC prompt. **Highlights of this fork** diff --git a/package.json b/package.json index df36d25..a7b3928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dynamiclight", - "version": "1.1.0", + "version": "1.1.1", "private": true, "license": "MIT", "author": "mskutin", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0577060..c712c0d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1227,7 +1227,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dynamiclight" -version = "1.1.0" +version = "1.1.1" dependencies = [ "cc", "chrono", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "1.1.0" +version = "1.1.1" dependencies = [ "dirs 6.0.0", "getrandom 0.3.4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3df97e4..400bdea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight" -version = "1.1.0" +version = "1.1.1" description = "Local USB monitor backlight controller" authors = ["mskutin"] license = "MIT" diff --git a/src-tauri/src/autostart_windows.rs b/src-tauri/src/autostart_windows.rs index 957e985..ed56052 100644 --- a/src-tauri/src/autostart_windows.rs +++ b/src-tauri/src/autostart_windows.rs @@ -295,9 +295,7 @@ pub fn startup() -> Option { }; // Installer: exit code 0 when this user already has the launch task. if has("--has-tasks") { - return Some(i32::from( - !exists(&launch_name(&me)) && !legacy_owned(LEGACY_LAUNCH, &me), - )); + return Some(i32::from(!admin_mode())); } if let Some(pid) = value(RELAUNCH).and_then(|pid| pid.parse().ok()) { wait_exit(pid); diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 590f6dd..49a2862 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -360,8 +360,10 @@ impl DisplayCapture { pub fn colors(&mut self, settings: &Settings) -> Result, String> { #[cfg(target_os = "windows")] { - // Once GDI has taken over (DXGI lost for good), retry far less often. - let retry_ms = if self.gdi_fallback { 2000 } else { 250 }; + // Once GDI has taken over, retry less often (a display that lost DXGI for + // good would otherwise rebuild it 4x/s), but still soon enough that a + // long lock screen or UAC prompt leaves GDI quickly after it ends. + let retry_ms = if self.gdi_fallback { 500 } else { 250 }; if self.desktop.is_none() && matches!(self.region, crate::capture_source::Source::Display {}) && self.retry.elapsed() >= std::time::Duration::from_millis(retry_ms) diff --git a/src-tauri/src/device.rs b/src-tauri/src/device.rs index f2f7979..9f06ead 100644 --- a/src-tauri/src/device.rs +++ b/src-tauri/src/device.rs @@ -59,14 +59,22 @@ pub fn hold_output(ms: u64) { pub fn guard_needed() -> bool { FAST_UNTIL_MS.load(std::sync::atomic::Ordering::Relaxed) > now_ms() } -fn held_for(fps: u8) -> Option { +/// Time left of a hold ending at `until` for a strip running at `fps`. +fn hold_remaining(fps: u8, until: u64, now: u64) -> Option { if fps <= HOLD_ABOVE_FPS { return None; } - let until = HOLD_UNTIL_MS.load(std::sync::atomic::Ordering::Relaxed); - let now = now_ms(); (until > now).then(|| Duration::from_millis(until - now)) } +fn held_for(fps: u8) -> Option { + let until = HOLD_UNTIL_MS.load(std::sync::atomic::Ordering::Relaxed); + hold_remaining(fps, until, now_ms()) +} +/// During a hold a frame is dropped when the last one went out less than +/// `HELD_FRAME_MS` ago. +fn skip_frame(held: bool, now: u64, last_frame: u64) -> bool { + held && now.saturating_sub(last_frame) < HELD_FRAME_MS +} /// Without administrator rights the lock-key guard cannot see key presses in /// elevated windows, so on Windows the output rate stays at 30 FPS, the highest /// rate at which indicator updates never collide with frames. @@ -404,9 +412,7 @@ impl Controller { pub fn frame(&mut self, colors: &[Rgb]) -> Result<(), String> { // Frames stream continuously; while a lock key is active drop down to ~30 FPS. let now = now_ms(); - if held_for(self.output_fps).is_some() - && now.saturating_sub(self.last_frame_ms) < HELD_FRAME_MS - { + if skip_frame(held_for(self.output_fps).is_some(), now, self.last_frame_ms) { return Ok(()); } self.last_frame_ms = now; @@ -459,14 +465,32 @@ mod tests { } #[test] fn lock_keys_hold_only_fast_strips() { - // No global hold here: other tests write frames through test buses. - assert!(held_for(30).is_none()); + // Pure helpers: no global hold, other tests write frames through test buses. + assert_eq!(hold_remaining(30, 1000, 0), None); + assert_eq!( + hold_remaining(60, 1000, 0), + Some(Duration::from_millis(1000)) + ); + assert_eq!(hold_remaining(60, 1000, 1000), None); + assert_eq!( + hold_remaining(HOLD_ABOVE_FPS + 1, 5, 0), + Some(Duration::from_millis(5)) + ); + } + #[test] + fn held_frames_are_thinned_to_about_30_fps() { + assert!(skip_frame(true, 100, 100 - HELD_FRAME_MS + 1)); + assert!(!skip_frame(true, 100, 100 - HELD_FRAME_MS)); + assert!(!skip_frame(false, 100, 99)); + } + #[test] + fn each_strip_keeps_its_own_output_rate() { let mut slow = Controller::new(); slow.set_output_fps(30); let mut fast = Controller::new(); fast.set_output_fps(60); - assert!(guard_needed()); assert_eq!((slow.output_fps, fast.output_fps), (30, 60)); + assert!(guard_needed()); } #[test] fn incomplete_write_stops_remaining_reports() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d48de72..6f4d1ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,7 +73,9 @@ fn admin_rights(app: tauri::AppHandle, enabled: Option) -> Result { let login = manager.is_enabled().unwrap_or(false) || autostart_windows::enabled(); let restart = autostart_windows::enable_admin(login)?; - let _ = manager.disable(); + // Not restarting with the Run key still there: it would start a second, + // unelevated copy at sign-in. + drop_run_key(&manager)?; if restart { app.exit(0); } @@ -82,7 +84,9 @@ fn admin_rights(app: tauri::AppHandle, enabled: Option) -> Result {} @@ -109,6 +113,15 @@ fn launch_at_login(app: tauri::AppHandle, enabled: Option) -> Result Result<(), String> { + match manager.disable() { + Err(e) if manager.is_enabled().unwrap_or(false) => Err(e.to_string()), + _ => Ok(()), + } +} + /// With administrator rights on, start at sign-in is the elevated login task; /// otherwise it is the per-user Run key. #[cfg(target_os = "windows")] @@ -120,9 +133,7 @@ fn login_item( if let Some(enabled) = enabled { if admin { // The Run key would start a second, unelevated copy. - if manager.is_enabled().unwrap_or(false) { - let _ = manager.disable(); - } + drop_run_key(manager)?; if enabled != autostart_windows::enabled() { if enabled { autostart_windows::enable()?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b2889ec..3edb86b 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": "1.1.0", + "version": "1.1.1", "identifier": "com.dynamiclight.desktop", "build": { "frontendDist": "../src/ui" diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh index c15e5ed..dff8ad1 100644 --- a/src-tauri/windows/installer-hooks.nsh +++ b/src-tauri/windows/installer-hooks.nsh @@ -25,9 +25,13 @@ !macroend !macro NSIS_HOOK_PREUNINSTALL - ; Interactive uninstall only: updates run the old uninstaller silently and must - ; keep start-up as it is. Removing tasks asks for permission only when one exists. + ; Interactive uninstall only. Updates run the old uninstaller silently (/S) or, in + ; the updater's passive mode, with /UPDATE (not silent); both must keep start-up as + ; it is. Removing tasks asks for permission only when one exists. IfSilent dl_remove_done + System::Call 'kernel32::GetCommandLineW() w .r0' + System::Call 'shlwapi::StrStrIW(w r0, w "/UPDATE") i .r1' + StrCmp $1 "0" 0 dl_remove_done DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" DeleteRegValue HKCU "Software\${PRODUCTNAME}" "StartupAsked" DeleteRegKey /ifempty HKCU "Software\${PRODUCTNAME}" diff --git a/src/ui/renderer.js b/src/ui/renderer.js index 5fb5783..a42b045 100644 --- a/src/ui/renderer.js +++ b/src/ui/renderer.js @@ -715,6 +715,25 @@ function initWorkspace() { }); } +// Switches Windows "Run with administrator rights" (also used by the FPS hint's +// button). Afterwards the real state is read back, so a declined UAC prompt or a +// half-done switch is shown as it is, including Launch at login (it moves between +// the Run key and the login task). +async function setAdminRights(enabled) { + try { + // Turning it on restarts the app; keep unsaved edits. + if (enabled) await save(); + await invoke("admin_rights", { enabled }); + } finally { + try { + adminRights = await invoke("admin_rights", { enabled: null }); + loginEnabled = await invoke("launch_at_login", { enabled: null }); + } catch { + // Keep what is known. + } + updateUI(); + } +} async function init() { platform = await invoke("platform_name"); initWorkspace(); @@ -751,11 +770,7 @@ async function init() { $("admin-rights-help").textContent = "Needs an administrator account. Without it the strip runs at up to 30 FPS."; } - $("elevate").onclick = () => - run(async () => { - await save(); - adminRights = await invoke("admin_rights", { enabled: true }); - }); + $("elevate").onclick = () => run(() => setAdminRights(true)); try { loginEnabled = await invoke("launch_at_login", { enabled: null }); } catch (error) { @@ -829,17 +844,8 @@ async function init() { $("admin-rights").onchange = (event) => { const enabled = event.target.checked; run(async () => { - try { - // Turning it on restarts the app; keep unsaved edits. - if (enabled) await save(); - adminRights = await invoke("admin_rights", { enabled }); - // Start at sign-in moves between the Run key and the login task. - loginEnabled = await invoke("launch_at_login", { enabled: null }); - } finally { - // A declined UAC prompt leaves the previous state in place. - event.target.checked = !!adminRights?.enabled; - } - if (!enabled && !adminRights.enabled && !fpsLimit) + await setAdminRights(enabled); + if (!enabled && !adminRights?.enabled && !fpsLimit) notice( "Administrator rights turn off the next time DynamicLight starts.", );