diff --git a/.claude/CODEMAP.md b/.claude/CODEMAP.md index 9d41f05..5fb8bac 100644 --- a/.claude/CODEMAP.md +++ b/.claude/CODEMAP.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 7542d6f..296f1cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` ждёт выхода старой и запускает задачу. - CI форка: только Windows, два параллельных джоба (checks: fmt/тесты/clippy; build: NSIS-установщик в Artifacts), без релизов, апдейтер-пакетов и CLI; везде быстрый release-профиль (без LTO, 16 codegen units) и один ключ кэша, чтобы PR брали кэш из main; `cancel-in-progress`. diff --git a/docs/installation.md b/docs/installation.md index 5129c09..9f8f5c6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 ()` (so shortcuts open the elevated app without a prompt) and `DynamicLight ()` (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 ()` (shortcuts open the elevated app without a prompt) and `DynamicLight ()` (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 diff --git a/src-tauri/src/autostart_windows.rs b/src-tauri/src/autostart_windows.rs index d0c7624..957e985 100644 --- a/src-tauri/src/autostart_windows.rs +++ b/src-tauri/src/autostart_windows.rs @@ -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 @@ -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, @@ -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, @@ -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) } @@ -299,27 +312,64 @@ pub fn startup() -> Option { None } +fn token_info(class: TOKEN_INFORMATION_CLASS) -> Option { + 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::() 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 = - std::sync::LazyLock::new(|| unsafe { IsUserAnAdmin() }.as_bool()); + static ELEVATED: std::sync::LazyLock = std::sync::LazyLock::new(|| { + token_info::(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::(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 { // 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18e1b68..d48de72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,18 +53,53 @@ fn fps_limit() -> Option { 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) -> Result { + 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) -> Result { + let _ = enabled; + Ok(AdminRights { + enabled: false, + available: false, + }) } // Runs off the main thread: enabling may wait for a UAC prompt. @@ -74,25 +109,41 @@ fn launch_at_login(app: tauri::AppHandle, enabled: Option) -> Result, ) -> Result { + 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"))] @@ -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, diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh index e9351c8..c15e5ed 100644 --- a/src-tauri/windows/installer-hooks.nsh +++ b/src-tauri/windows/installer-hooks.nsh @@ -2,27 +2,35 @@ ; SHCNE_UPDATEITEM + SHCNF_PATHW | SHCNF_FLUSHNOWAIT. No cache deletion/restart. ; https://learn.microsoft.com/windows/win32/api/shlobj_core/nf-shlobj_core-shchangenotify ; -; DynamicLight runs elevated through two scheduled tasks (see autostart_windows.rs): -; "DynamicLight Launch (user)" lets shortcuts open the elevated app, "DynamicLight (user)" -; starts it at sign-in (one pair per Windows user). The installer offers them once; -; silent installs and updates that find the tasks already registered do not ask again. +; Start with Windows is the per-user Run key, the same value the app's Launch at +; login writes (tauri-plugin-autostart: name = product name, --autostart). The +; installer asks once per user, without UAC; silent installs and updates never ask. +; Administrator rights (Task Scheduler tasks, see autostart_windows.rs) are an +; option in the app; users who already have the tasks keep them and are not asked. + !macro NSIS_HOOK_POSTINSTALL System::Call 'shell32::SHChangeNotify(i 0x00002000, i 0x3005, w "$INSTDIR\${MAINBINARYNAME}.exe", p 0)' - IfSilent dl_tasks_done + IfSilent dl_startup_done + ReadRegDWORD $0 HKCU "Software\${PRODUCTNAME}" "StartupAsked" + StrCmp $0 "1" dl_startup_done + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + StrCmp $0 "" 0 dl_startup_asked ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --has-tasks' $0 - StrCmp $0 "0" dl_tasks_done - MessageBox MB_YESNO|MB_ICONQUESTION "Start DynamicLight with Windows?" IDNO dl_tasks_nologin - ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --setup-tasks login' - Goto dl_tasks_done - dl_tasks_nologin: - ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --setup-tasks' - dl_tasks_done: + StrCmp $0 "0" dl_startup_asked + MessageBox MB_YESNO|MB_ICONQUESTION "Start DynamicLight with Windows?" IDNO dl_startup_asked + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" '"$INSTDIR\${MAINBINARYNAME}.exe" --autostart' + dl_startup_asked: + WriteRegDWORD HKCU "Software\${PRODUCTNAME}" "StartupAsked" 1 + dl_startup_done: !macroend !macro NSIS_HOOK_PREUNINSTALL ; Interactive uninstall only: updates run the old uninstaller silently and must - ; keep the tasks. Asks for permission only when a task exists. + ; keep start-up as it is. Removing tasks asks for permission only when one exists. IfSilent dl_remove_done + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + DeleteRegValue HKCU "Software\${PRODUCTNAME}" "StartupAsked" + DeleteRegKey /ifempty HKCU "Software\${PRODUCTNAME}" IfFileExists "$INSTDIR\${MAINBINARYNAME}.exe" 0 dl_remove_done ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --remove-tasks' dl_remove_done: diff --git a/src/ui/index.html b/src/ui/index.html index 692eaed..7e4ffc5 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -724,8 +724,11 @@

Device setup

Without administrator rights the strip runs at up to 30 FPS: faster updates can make it flash when Caps Lock, Num Lock or Scroll Lock is pressed in an administrator window. -

-

- On Windows it starts with administrator rights so lock-key - protection works in every window; Windows asks once. -

+ run(async () => { await save(); - await invoke("elevate"); + adminRights = await invoke("admin_rights", { enabled: true }); }); try { loginEnabled = await invoke("launch_at_login", { enabled: null }); @@ -809,6 +826,25 @@ 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) + notice( + "Administrator rights turn off the next time DynamicLight starts.", + ); + }); + }; $("effect-direction").onchange = () => { settings.effectReverse = $("effect-direction").value === "reverse"; changed(["effectReverse"]);