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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 отклонён.
Expand Down
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 = "1.1.0"
version = "1.1.1"
edition = "2021"
license = "MIT"
rust-version = "1.99"
Expand Down
11 changes: 5 additions & 6 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -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**

Expand Down
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": "1.1.0",
"version": "1.1.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 = "1.1.0"
version = "1.1.1"
description = "Local USB monitor backlight controller"
authors = ["mskutin"]
license = "MIT"
Expand Down
4 changes: 1 addition & 3 deletions src-tauri/src/autostart_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,7 @@ pub fn startup() -> Option<i32> {
};
// 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);
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,10 @@ impl DisplayCapture {
pub fn colors(&mut self, settings: &Settings) -> Result<Vec<Rgb>, 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)
Expand Down
42 changes: 33 additions & 9 deletions src-tauri/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration> {
/// Time left of a hold ending at `until` for a strip running at `fps`.
fn hold_remaining(fps: u8, until: u64, now: u64) -> Option<Duration> {
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<Duration> {
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
21 changes: 16 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ fn admin_rights(app: tauri::AppHandle, enabled: Option<bool>) -> Result<AdminRig
Some(true) if !current || !autostart_windows::is_elevated() => {
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);
}
Expand All @@ -82,7 +84,9 @@ fn admin_rights(app: tauri::AppHandle, enabled: Option<bool>) -> Result<AdminRig
let login = autostart_windows::enabled();
autostart_windows::disable_admin()?;
if login {
manager.enable().map_err(|e| e.to_string())?;
manager.enable().map_err(|e| {
format!("Administrator rights are off, but Launch at login could not be restored: {e}")
})?;
}
}
_ => {}
Expand All @@ -109,6 +113,15 @@ fn launch_at_login(app: tauri::AppHandle, enabled: Option<bool>) -> Result<bool,
login_item(&app.autolaunch(), enabled)
}

/// Removes the per-user Run key; fails only if it is still there afterwards.
#[cfg(target_os = "windows")]
fn drop_run_key(manager: &tauri_plugin_autostart::AutoLaunchManager) -> 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")]
Expand All @@ -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()?;
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": "1.1.0",
"version": "1.1.1",
"identifier": "com.dynamiclight.desktop",
"build": {
"frontendDist": "../src/ui"
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/windows/installer-hooks.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
38 changes: 22 additions & 16 deletions src/ui/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.",
);
Expand Down
Loading