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/CODEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

**Система (Windows)**
- `lifecycle.rs` / `lifecycle_windows.rs` — сон/выход/блокировка, WM_ENDSESSION → Shutdown, хук lock-клавиш.
- `autostart_windows.rs` — задачи Планировщика (COM), `--setup-tasks`, `--relaunch-after`, `elevate`.
- `autostart_windows.rs` — задачи Планировщика (COM), `--setup-tasks`, `--relaunch-after`, опция `admin_rights` (`admin_mode`, `enable_admin`, `can_elevate`).
- `tray.rs`, `compact.rs` (Quick controls), `platform.rs`, `linux.rs`.
- `updates.rs` — апдейтер (доверяет только BrenOne releases).

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@
- Автонастройка без участия пользователя: при подключении `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)` чтобы обычный запуск ярлыка передавал управление админской копии.
- Тёмные сцены (`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 отключены, под выбором предупреждение и кнопка «Allow administrator rights» (`elevate`: создаёт launch-задачу одним UAC и перезапускает приложение через неё ~4 с).
- Установщик (`windows/installer-hooks.nsh`): после установки один раз спрашивает «Start DynamicLight with Windows?» и вызывает `dynamiclight.exe --setup-tasks [login]` (один UAC). Задачи: `DynamicLight` (вход в систему, `--autostart`) и `DynamicLight Launch` (без триггера, `--from-task`, Parallel). Обычный запуск без прав сам запускает `DynamicLight Launch` и выходит (`autostart_windows::startup`) — ярлыки и «Запустить» в установщике открывают программу от администратора без UAC. Тихие установки/обновления не спрашивают; интерактивная деинсталляция `--remove-tasks`.
- 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 отклонён.
- Установщик (`windows/installer-hooks.nsh`): один раз на пользователя (маркер `HKCU\Software\DynamicLight\StartupAsked`) спрашивает «Start DynamicLight with Windows?» и пишет Run-ключ, без UAC и задач. Не спрашивает, если Run-ключ или задачи уже есть (`--has-tasks`). Задачи (если опция включена): `DynamicLight (user)` (вход в систему, `--autostart`) и `DynamicLight Launch (user)` (без триггера, `--from-task`, Parallel). Обычный запуск без прав сам запускает launch-задачу и выходит (`autostart_windows::startup`). Тихие установки/обновления не спрашивают; интерактивная деинсталляция удаляет Run-ключ и `--remove-tasks`.
- Defender ругался `Behavior:Win32/Persistence.A!ml` на «Allow administrator rights» и удалял программу (шаблон `cmd /c ping … & schtasks`, плюс `ping` открывал окно консоли). Теперь (`autostart_windows.rs`): задачи создаются через COM Планировщика (`ITaskFolder::RegisterTask` с XML, без temp-файлов и `schtasks.exe`); UAC — запуск самого `dynamiclight.exe` (`--setup-tasks [login] --user …`, `--remove-tasks`, `--remove-login-task`); перезапуск — копия с `--relaunch-after <pid>` ждёт выхода старой и запускает задачу.
- CI форка: только Windows, два параллельных джоба (checks: fmt/тесты/clippy; build: NSIS-установщик в Artifacts), без релизов, апдейтер-пакетов и CLI; везде быстрый release-профиль (без LTO, 16 codegen units) и один ключ кэша, чтобы PR брали кэш из main; `cancel-in-progress`.

Expand Down
11 changes: 6 additions & 5 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ The updater uses a dedicated signing key embedded in the app. This verification

## Windows administrator rights

DynamicLight is meant to run with administrator rights on Windows: Windows updates the keyboard indicators (Caps/Num/Scroll Lock) through the strip's controller, and that makes the strip flash at high frame rates. With administrator rights the app can see those key presses in every window and avoids the flashes at up to 60 FPS. Without them, output is capped at **30 FPS**, which is always stable; **Settings** shows the cap and a button to restart elevated.
Windows updates the keyboard indicators (Caps/Num/Scroll Lock) through the strip's controller, and above 30 FPS that can make the strip flash. Seeing those key presses in every window needs administrator rights, so by default DynamicLight runs without them and caps output at **30 FPS**, which is always stable.

- The interactive installer asks once whether to **Start DynamicLight with Windows?** and approves the required tasks with a single UAC prompt. Updates and silent installs do not ask again when the tasks already exist.
- The tasks are `DynamicLight Launch (<user>)` (so shortcuts open the elevated app without a prompt) and `DynamicLight (<user>)` (start at sign-in, only if you chose it). Every Windows user gets their own pair; they appear in Task Scheduler.
- They are created through the Windows API, not through `cmd.exe` or `schtasks`, because antivirus heuristics treat that pattern as persistence malware.
- **Launch at login** in the app toggles the sign-in task (one UAC prompt). Interactive uninstall asks for permission once and removes the tasks.
- **Settings → Startup → Run with administrator rights** (or the button under the FPS choice) unlocks 45–120 FPS. Windows asks once and the app restarts elevated. It needs an administrator account; for standard users the option is unavailable.
- It works through two Task Scheduler tasks per Windows user: `DynamicLight Launch (<user>)` (shortcuts open the elevated app without a prompt) and `DynamicLight (<user>)` (start at sign-in, when **Launch at login** is on). They are created through the Windows API, not through `cmd.exe` or `schtasks`, because antivirus heuristics treat that pattern as persistence malware.
- Turning the option off removes the tasks (one UAC prompt); it takes effect the next time the app starts.
- **Launch at login** uses the per-user Run key without administrator rights and the sign-in task with them. The interactive installer asks once whether to **Start DynamicLight with Windows?**, without a UAC prompt. Updates and silent installs do not ask. Installations that already have the tasks keep them.
- Interactive uninstall removes the Run entry and, with one UAC prompt, the tasks.

## Command-line controls and uninstall

Expand Down
78 changes: 64 additions & 14 deletions src-tauri/src/autostart_windows.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Elevated start-up through Task Scheduler.
//! Elevated start-up through Task Scheduler ("Run with administrator rights").
//!
//! The option is off by default: then Launch at login is the per-user Run key
//! and DynamicLight runs without administrator rights (up to 30 FPS).
//! An elevated process is needed so the lock-key guard (a low-level keyboard
//! hook) also sees key presses while an administrator window is in front; Windows
//! hides those from non-elevated hooks. Two tasks run DynamicLight with the
Expand All @@ -17,7 +19,11 @@
use windows::{
core::{w, BSTR, HSTRING, PCWSTR},
Win32::{
Foundation::CloseHandle,
Foundation::{CloseHandle, HANDLE},
Security::{
GetTokenInformation, TokenElevation, TokenElevationType, TokenElevationTypeLimited,
TOKEN_ELEVATION, TOKEN_ELEVATION_TYPE, TOKEN_INFORMATION_CLASS, TOKEN_QUERY,
},
System::{
Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
Expand All @@ -28,14 +34,14 @@ use windows::{
TASK_LOGON_INTERACTIVE_TOKEN,
},
Threading::{
GetExitCodeProcess, OpenProcess, WaitForSingleObject, INFINITE, PROCESS_SYNCHRONIZE,
GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken,
WaitForSingleObject, INFINITE, PROCESS_SYNCHRONIZE,
},
Variant::VARIANT,
},
UI::{
Shell::{
IsUserAnAdmin, ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SEE_MASK_NO_CONSOLE,
SHELLEXECUTEINFOW,
ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SEE_MASK_NO_CONSOLE, SHELLEXECUTEINFOW,
},
WindowsAndMessaging::{
ChangeWindowMessageFilter, FindWindowExW, FindWindowW, MSGFLT_ADD, SW_HIDE,
Expand Down Expand Up @@ -142,6 +148,13 @@ pub fn enabled() -> bool {
current_user().is_ok_and(|user| exists(&task_name(&user)) || legacy_owned(LEGACY_TASK, &user))
}

/// Whether "Run with administrator rights" is on: this user has the launch task.
pub fn admin_mode() -> bool {
current_user()
.is_ok_and(|user| exists(&launch_name(&user)) || legacy_owned(LEGACY_LAUNCH, &user))
}

/// Login task (administrator rights on).
pub fn enable() -> Result<(), String> {
register(true, None)
}
Expand Down Expand Up @@ -299,27 +312,64 @@ pub fn startup() -> Option<i32> {
None
}

fn token_info<T: Default>(class: TOKEN_INFORMATION_CLASS) -> Option<T> {
unsafe {
let mut token = HANDLE::default();
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).ok()?;
let mut value = T::default();
let mut size = 0;
let result = GetTokenInformation(
token,
class,
Some((&mut value as *mut T).cast()),
std::mem::size_of::<T>() as u32,
&mut size,
);
let _ = CloseHandle(token);
result.ok().map(|_| value)
}
}

/// Whether this process has administrator rights (cached).
pub fn is_elevated() -> bool {
static ELEVATED: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| unsafe { IsUserAnAdmin() }.as_bool());
static ELEVATED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
token_info::<TOKEN_ELEVATION>(TokenElevation).is_some_and(|t| t.TokenIsElevated != 0)
});
*ELEVATED
}

/// Registers the launch task (one UAC prompt) and restarts through it, so the
/// app comes back elevated. A helper copy of DynamicLight waits for this one to
/// exit (otherwise single-instance would hand the new start back to it) and
/// then runs the task; it is a GUI process, so no console window appears.
pub fn elevate_and_restart() -> Result<(), String> {
/// Whether this account can get administrator rights: elevated already, or an
/// administrator under UAC. A standard user's task would run without them.
pub fn can_elevate() -> bool {
is_elevated()
|| token_info::<TOKEN_ELEVATION_TYPE>(TokenElevationType)
.is_some_and(|t| t == TokenElevationTypeLimited)
}

/// Turns administrator rights on: registers the launch task (and the login task
/// when `login`) with one UAC prompt. Returns true when this copy must exit: a
/// helper copy of DynamicLight waits for it (otherwise single-instance would
/// hand the new start back to it) and then runs the task, so the app comes back
/// elevated; it is a GUI process, so no console window appears.
pub fn enable_admin(login: bool) -> Result<bool, String> {
// Always (re)create it: a task that exists but is disabled would not run,
// and the app would close without coming back.
register(false, None)?;
register(login, None)?;
if is_elevated() {
return Ok(false);
}
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
std::process::Command::new(exe)
.args([RELAUNCH, &std::process::id().to_string()])
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
Ok(true)
}

/// Turns administrator rights off: removes this user's tasks (one UAC prompt
/// unless elevated). A running elevated copy keeps its rights until it exits.
pub fn disable_admin() -> Result<(), String> {
remove(true, None)
}

/// At sign-in the task starts before Explorer has created the notification area,
Expand Down
83 changes: 67 additions & 16 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,53 @@ fn fps_limit() -> Option<u8> {
device::fps_limit()
}

/// Windows: get administrator rights (one UAC prompt) and restart elevated.
#[derive(serde::Serialize)]
struct AdminRights {
enabled: bool,
available: bool,
}

/// Windows "Run with administrator rights": start through the elevated Task
/// Scheduler tasks instead of the Run key (one UAC prompt to switch). Turning
/// it on restarts the app elevated.
#[cfg(target_os = "windows")]
#[tauri::command(async)]
fn elevate(app: tauri::AppHandle) -> Result<(), String> {
autostart_windows::elevate_and_restart()?;
app.exit(0);
Ok(())
fn admin_rights(app: tauri::AppHandle, enabled: Option<bool>) -> Result<AdminRights, String> {
use tauri_plugin_autostart::ManagerExt;
let manager = app.autolaunch();
let current = autostart_windows::admin_mode();
match enabled {
// Also when on but not elevated: re-registering repairs a disabled task.
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();
if restart {
app.exit(0);
}
}
Some(false) if current => {
let login = autostart_windows::enabled();
autostart_windows::disable_admin()?;
if login {
manager.enable().map_err(|e| e.to_string())?;
}
}
_ => {}
}
Ok(AdminRights {
enabled: autostart_windows::admin_mode(),
available: autostart_windows::can_elevate(),
})
}
#[cfg(not(target_os = "windows"))]
#[tauri::command]
fn elevate() -> Result<(), String> {
Err("Not needed on this platform".into())
fn admin_rights(enabled: Option<bool>) -> Result<AdminRights, String> {
let _ = enabled;
Ok(AdminRights {
enabled: false,
available: false,
})
}

// Runs off the main thread: enabling may wait for a UAC prompt.
Expand All @@ -74,25 +109,41 @@ fn launch_at_login(app: tauri::AppHandle, enabled: Option<bool>) -> Result<bool,
login_item(&app.autolaunch(), enabled)
}

/// 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")]
fn login_item(
manager: &tauri_plugin_autostart::AutoLaunchManager,
enabled: Option<bool>,
) -> Result<bool, String> {
let admin = autostart_windows::admin_mode();
if let Some(enabled) = enabled {
// Older versions used the per-user Run key; the elevated task replaces it.
if manager.is_enabled().unwrap_or(false) {
let _ = manager.disable();
}
if enabled != autostart_windows::enabled() {
if admin {
// The Run key would start a second, unelevated copy.
if manager.is_enabled().unwrap_or(false) {
let _ = manager.disable();
}
if enabled != autostart_windows::enabled() {
if enabled {
autostart_windows::enable()?;
} else {
autostart_windows::disable()?;
}
}
} else {
if enabled {
autostart_windows::enable()?;
manager.enable()
} else {
autostart_windows::disable()?;
manager.disable()
}
.map_err(|e| e.to_string())?;
}
}
Ok(autostart_windows::enabled())
Ok(if admin {
autostart_windows::enabled()
} else {
manager.is_enabled().unwrap_or(false)
})
}

#[cfg(not(target_os = "windows"))]
Expand Down Expand Up @@ -293,7 +344,7 @@ pub fn run() {
effect_previews,
launch_at_login,
fps_limit,
elevate,
admin_rights,
profiles::profile_library,
profiles::apply_profile
,diagnostics::diagnostic_report,
Expand Down
Loading
Loading