diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e024ce..23d419a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,11 +17,21 @@ jobs: global-json-file: global.json - shell: powershell run: | - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile("$PWD\scripts\Test-Milestones.ps1", [ref]$tokens, [ref]$errors) | Out-Null - if ($errors) { throw ($errors | Out-String) } + $scriptRoots = @( + "$PWD\scripts", + "$PWD\src\OpenSynapse.PowerShell" + ) + foreach ($script in Get-ChildItem $scriptRoots -Recurse -File | Where-Object { $_.Extension -in @('.ps1', '.psm1') }) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors) { throw ($errors | Out-String) } + } + & "$PWD\scripts\Test-InstallerDefinitions.ps1" + & "$PWD\scripts\Test-Milestones.ps1" - run: dotnet restore OpenSynapse.sln - run: dotnet format OpenSynapse.sln --verify-no-changes --no-restore - run: dotnet build OpenSynapse.sln --configuration Release --no-restore - - run: dotnet test tests/OpenSynapse.Core.Tests/OpenSynapse.Core.Tests.csproj --configuration Release --no-build + - run: dotnet test OpenSynapse.sln --configuration Release --no-build --no-restore + - shell: powershell + run: .\scripts\Publish-OpenSynapse.ps1 diff --git a/CONTEXT.md b/CONTEXT.md index 43fb16b..e025d9d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ OpenSynapse controls supported Windows system and Razer device capabilities whil ## Language **Operating Mode**: -A named set of desired system states. OpenSynapse currently defines Performance and Quiet. +A named set of desired system states. OpenSynapse currently defines Performance, Balanced, and Quiet. _Avoid_: Profile, preset **Mode Selection**: @@ -13,7 +13,7 @@ The user's instruction for choosing an Operating Mode. It may name a mode direct _Avoid_: Mode, profile **Auto**: -A Mode Selection that resolves to Performance on AC power and Quiet on battery or an unknown source. +A Mode Selection that resolves to Performance only on verified high-power AC and Quiet on low-power, battery, ambiguous, or unknown input. _Avoid_: Auto mode **Capability**: diff --git a/OpenSynapse.sln b/OpenSynapse.sln index f99171f..82dc25c 100644 --- a/OpenSynapse.sln +++ b/OpenSynapse.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenSynapse.Core.Tests", "tests\OpenSynapse.Core.Tests\OpenSynapse.Core.Tests.csproj", "{FC81F739-55A3-45D4-8804-525103EE97BB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenSynapse.Agent.Tests", "tests\OpenSynapse.Agent.Tests\OpenSynapse.Agent.Tests.csproj", "{4B759D92-1D8F-460A-8D89-A8974BF21A30}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,18 @@ Global {FC81F739-55A3-45D4-8804-525103EE97BB}.Release|x64.Build.0 = Release|Any CPU {FC81F739-55A3-45D4-8804-525103EE97BB}.Release|x86.ActiveCfg = Release|Any CPU {FC81F739-55A3-45D4-8804-525103EE97BB}.Release|x86.Build.0 = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|x64.Build.0 = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Debug|x86.Build.0 = Debug|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|Any CPU.Build.0 = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|x64.ActiveCfg = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|x64.Build.0 = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|x86.ActiveCfg = Release|Any CPU + {4B759D92-1D8F-460A-8D89-A8974BF21A30}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,5 +96,6 @@ Global {F946D864-5D2F-4DC8-8701-2C996F9435C7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7DA3B07C-2DA5-4088-8BBE-250FE32D703A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {FC81F739-55A3-45D4-8804-525103EE97BB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {4B759D92-1D8F-460A-8D89-A8974BF21A30} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index b104b9c..8567c23 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ OpenSynapse is a local-first, open-source Windows control center for supported Razer hardware and system policies. Its goal is to replace opaque background software with explicit capabilities, inspectable state changes, and reliable rollback. > [!WARNING] -> OpenSynapse is experimental, distributed as source only, and has no stable release. The current DeathAdder implementation still requires verification on target hardware. Keep Razer Synapse closed while testing device control. +> The power/display runtime is now based directly on the field-tested PowerPilot 2.4.1 implementation. DeathAdder writes remain hardware-gated and still require verification on a connected target device. Keep Razer Synapse closed while testing device control. ## Principles @@ -23,9 +23,9 @@ OpenSynapse currently implements the M0–M3 development slice. Implementation d | Area | Current capability | Maturity | | --- | --- | --- | -| Windows policies | Auto, Performance, and Quiet selection; power plans; refresh rate; Advanced Color/HDR; internal brightness; display scaling | Implemented, target-Windows validation pending | -| State restoration | Atomic captured state and verified power-plan rollback | Implemented, target-Windows validation pending | -| Desktop control | Non-elevated WPF panel and tray UI connected to a per-user elevated agent | Implemented, target-Windows validation pending | +| Windows policies | Adapter-aware Auto, Hyper, Balance, and Quiet selection; power plans; refresh rate; Advanced Color/HDR; internal brightness; display scaling; optional wake-device control | Live installation validated on the target RZ09-0528 | +| State restoration | Atomic captured state, legacy .NET rollback, PowerPilot takeover, and verified power-plan rollback | Migration validated on the target system | +| Desktop control | Single elevated PowerShell 5.1/WinForms tray process, installed as a delayed highest-privilege per-user task | Installed and live-validated | | Razer mouse | Discovery, status, DPI, and standard-receiver polling control | Experimental | ### Device matrix @@ -41,57 +41,77 @@ OpenSynapse currently implements the M0–M3 development slice. Implementation d ## Architecture -OpenSynapse separates the ordinary desktop UI from privileged Windows operations: +The release runtime intentionally follows PowerPilot 2.4.1's proven single-process model: ```text -OpenSynapse.App ── current-user named pipe ──> OpenSynapse.Agent - │ │ - └──────── shared request models ──────────────┤ - ├─ Windows policy APIs / powercfg - └─ capability-gated Razer HID +OpenSynapse scheduled task (highest privileges, STA) + │ + └─ OpenSynapse.ps1 (WinForms UI, tray, automation) + └─ dynamically compiled OpenSynapse.Native.cs + ├─ display, battery and GPU telemetry + ├─ Windows policy APIs / powercfg + └─ capability-gated DeathAdder HID reports ``` -The agent owns mode selection, captured state, rollback, and hardware writes. The UI never writes privileged system or HID state directly. See [Architecture](docs/ARCHITECTURE.md) and the shared [domain language](CONTEXT.md). +This removes the WPF-to-Agent startup and named-pipe failure mode that made the previous migration appear online while its control backend was unavailable. See [Architecture](docs/ARCHITECTURE.md). ## Build and test Requirements: - Windows 11 -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0), matching [`global.json`](global.json) -- An elevated PowerShell terminal only for system-policy and hardware smoke tests +- Windows PowerShell 5.1 +- Administrator approval for installation and policy changes ```powershell -dotnet restore OpenSynapse.sln -dotnet build OpenSynapse.sln --no-restore -dotnet test tests/OpenSynapse.Core.Tests/OpenSynapse.Core.Tests.csproj --no-build +powershell -ExecutionPolicy Bypass -File scripts\Test-InstallerDefinitions.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 ``` -Start the elevated agent, then launch the UI from a normal terminal: +The retained .NET solution contains protocol/unit-test code from the previous implementation and can still be tested separately, but it is no longer the published desktop runtime. + +### Publish and install + +Create the renamed PowerPilot-compatible package, then install it: ```powershell -dotnet run --project src/OpenSynapse.Agent -- serve -dotnet run --project src/OpenSynapse.App +powershell -ExecutionPolicy Bypass -File scripts\Publish-OpenSynapse.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Install-OpenSynapse.ps1 ``` -On a disposable or fully understood Windows configuration, run the reversible elevated smoke test: +The package is written to `artifacts\publish\OpenSynapse` and `artifacts\OpenSynapse-2.4.2.zip`. The installer first asks the obsolete .NET Agent to restore its captured state when that binary is available; otherwise it restores the legacy power, display, brightness and wake state directly. If an installed PowerPilot runtime exists, its configuration and recovery state are archived, its own verified uninstaller restores Windows, and that configuration is promoted to OpenSynapse. The installer then removes the obsolete split runtime, copies the PowerShell implementation under `%ProgramFiles%\OpenSynapse`, registers one delayed highest-privilege per-user task, and creates an OpenSynapse Start menu shortcut. To uninstall and restore the captured state: ```powershell -powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Uninstall-OpenSynapse.ps1 +``` + +On a disposable or fully understood Windows configuration, run the full reversible administrator suite: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 -AdminRelease # Optional: re-write the mouse's currently reported values to verify HID transport. powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 -TestMouseWrites ``` -The script verifies Performance/Quiet application, named-pipe lifecycle, agent shutdown, power-plan rollback, and captured-state cleanup. The mouse option requires a readable DeathAdder V3 Pro and does not intentionally select new values. +The mouse option requires a readable DeathAdder V3 Pro and writes its currently reported values back without intentionally choosing new settings. + +## Configuration + +The tray runtime stores policy in `%LOCALAPPDATA%\OpenSynapse\config.json` and rollback state in `state.json`. It retains PowerPilot 2.4.1's Smart Auto, application rules, supply debounce, display policy, battery telemetry, health backoff, and reversible state model. + +The PowerPilot-compatible defaults enable Quiet maintenance. Wake devices are matched by configured name fragments (initially MediaTek Wi-Fi, HID-compliant mouse, and USB4); tracked permissions are restored when leaving Quiet, exiting, or uninstalling. Quiet may also close configured high-drain helper processes and pause configured Armoury Crate/ASUS services. Review these lists in `config.json` if those applications or devices must remain active. ## Safety and privacy - Razer writes require an exact supported VID/PID and Consumer HID usage page. - DPI and polling inputs are validated before packet construction. - Responses must match the request transaction, command class, command ID, and checksum. -- Privileged IPC is restricted to the current Windows user. -- Captured system state is stored atomically under `%LOCALAPPDATA%\OpenSynapse`. +- The installed runtime runs as a single per-user highest-privilege scheduled task; no named-pipe IPC is required. +- Configuration and captured system state are stored separately and atomically under `%LOCALAPPDATA%\OpenSynapse`. +- Quiet wake-device and service changes retain rollback state; configured process termination is limited to the local allowlists inherited from PowerPilot. +- Runtime events are written locally to bounded `OpenSynapse.log` and `telemetry.jsonl` files. +- Adapter classification invokes `nvidia-smi` with a read-only query and fails safe to Quiet when the power limit cannot be verified. - The current implementation contains no telemetry, analytics, updater, account system, or runtime network client. Please report security issues through the private process in [SECURITY.md](SECURITY.md), not a public issue. diff --git a/README.zh-CN.md b/README.zh-CN.md index 461d98e..5f37eac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,7 @@ OpenSynapse 是一个本地优先的开源 Windows 控制中心,用于管理受支持的 Razer 硬件和系统策略。项目的目标是用能力明确、状态变化可检查、可靠回滚的开放实现,替代不透明的常驻软件。 > [!WARNING] -> OpenSynapse 仍处于实验阶段,目前只提供源码,没有稳定版本。现有 DeathAdder 实现仍需在目标硬件上完成验证。测试设备控制时,请关闭 Razer Synapse。 +> 电源与显示运行时现已直接建立在实机验证过的 PowerPilot 2.4.1 上。DeathAdder 写入仍受硬件白名单保护,并需要连接目标设备完成实测。测试设备控制时,请关闭 Razer Synapse。 ## 项目原则 @@ -23,9 +23,10 @@ OpenSynapse 已实现 M0–M3 开发切片。完成代码实现不等于通过 | 领域 | 当前能力 | 成熟度 | | --- | --- | --- | -| Windows 策略 | Auto、Performance、Quiet;电源方案、刷新率、Advanced Color/HDR、内屏亮度和显示缩放 | 已实现,等待目标 Windows 验证 | -| 状态恢复 | 原始状态原子保存与电源方案恢复验证 | 已实现,等待目标 Windows 验证 | -| 桌面控制 | 普通权限 WPF 面板和托盘 UI,通过当前用户专用管道连接提权 Agent | 已实现,等待目标 Windows 验证 | +| Windows 策略 | Smart Auto、Hyper、Balance、Quiet;电源方案、刷新率、Advanced Color/HDR、内屏亮度和显示缩放 | 已在目标 RZ09-0528 完成真实安装验证 | +| Smart Auto | 供电、CPU/GPU、前台/全屏应用、应用规则、迟滞、临时模式、dGPU 连续活动诊断 | 已实现;GPU 不可用时安全回退到 CPU/窗口信号 | +| 状态恢复 | 原始状态原子保存、旧 .NET 恢复、PowerPilot 接管与电源方案恢复验证 | 已在目标系统完成迁移验证 | +| 桌面控制 | 单个提权的 PowerShell 5.1/WinForms 托盘进程,通过延迟且最高权限的当前用户计划任务启动 | 已安装并完成现场验证 | | Razer 鼠标 | 设备发现、状态、DPI 和标准接收器轮询率控制 | 实验性 | ### 设备矩阵 @@ -41,58 +42,64 @@ OpenSynapse 已实现 M0–M3 开发切片。完成代码实现不等于通过 ## 架构 -OpenSynapse 将普通权限桌面 UI 与高权限 Windows 操作分离: +发布版现在沿用 PowerPilot 2.4.1 已验证的单进程模型: ```text -OpenSynapse.App ── 当前用户专用命名管道 ──> OpenSynapse.Agent - │ │ - └────────── 共享请求模型 ───────────────────────┤ - ├─ Windows 策略 API / powercfg - └─ 能力门控的 Razer HID +OpenSynapse 计划任务(最高权限、STA) + │ + └─ OpenSynapse.ps1(WinForms UI、托盘、自动化) + └─ 动态编译 OpenSynapse.Native.cs + ├─ 显示、电池和 GPU 遥测 + ├─ Windows 策略 API / powercfg + └─ 受白名单保护的 DeathAdder HID 报告 ``` -Agent 负责模式选择、状态捕获、回滚和硬件写入;UI 不直接写入高权限系统状态或 HID。详情见[架构文档](docs/ARCHITECTURE.md)和共享[领域语言](CONTEXT.md)。 +这样移除了上一版 WPF 与 Agent 之间的启动、UAC 和命名管道故障点。详情见[架构文档](docs/ARCHITECTURE.md)。 ## 构建与测试 要求: - Windows 11 -- 与 [`global.json`](global.json) 匹配的 [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -- 仅系统策略和硬件冒烟测试需要管理员 PowerShell +- Windows PowerShell 5.1 +- 安装和系统策略变更需要管理员确认 ```powershell -dotnet restore OpenSynapse.sln -dotnet build OpenSynapse.sln --no-restore -dotnet test tests/OpenSynapse.Core.Tests/OpenSynapse.Core.Tests.csproj --no-build +powershell -ExecutionPolicy Bypass -File scripts\Test-InstallerDefinitions.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 ``` -先在管理员终端启动 Agent,再从普通终端启动 UI: +保留的 .NET 解决方案仍包含上一版实现的协议和单元测试代码,但不再作为发布版桌面运行时。 + +发布并安装: ```powershell -dotnet run --project src/OpenSynapse.Agent -- serve -dotnet run --project src/OpenSynapse.App +powershell -ExecutionPolicy Bypass -File scripts\Publish-OpenSynapse.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Install-OpenSynapse.ps1 ``` -在可随时恢复、配置完全明确的 Windows 环境中运行可逆冒烟测试: +发布结果位于 `artifacts\publish\OpenSynapse` 和 `artifacts\OpenSynapse-2.4.2.zip`。如果旧 .NET Agent 仍存在,安装器会先调用它恢复已捕获状态;如果旧二进制已不存在,则直接恢复旧状态中的电源计划、显示、亮度和唤醒权限。如果检测到已安装且正在运行的 PowerPilot,安装器会归档它的配置和恢复状态,调用 PowerPilot 自身的卸载流程恢复 Windows,并把用户配置提升为 OpenSynapse 配置。随后才会清理旧运行时、安装 `%ProgramFiles%\OpenSynapse`、注册唯一的最高权限当前用户任务并创建开始菜单快捷方式。 + +在可随时恢复、配置完全明确的 Windows 环境中运行完整管理员测试: ```powershell -powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 +powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 -AdminRelease # 可选:把鼠标当前报告的数值写回,用于验证 HID 传输。 powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 -TestMouseWrites ``` -脚本会验证 Performance/Quiet 应用、命名管道生命周期、Agent 关闭、电源方案回滚和状态快照清理。鼠标选项需要可读取的 DeathAdder V3 Pro,并且不会主动选择新的参数。 +鼠标选项需要可读取的 DeathAdder V3 Pro,并把当前值写回,不会主动选择新的参数。 ## 安全与隐私 - Razer 写入要求精确匹配受支持的 VID/PID 和 Consumer HID Usage Page。 - DPI 和轮询率在构造报文前完成验证。 - 响应必须匹配事务、命令类、命令 ID 和校验和。 -- 高权限 IPC 只允许当前 Windows 用户访问。 +- 安装版以当前用户的最高权限计划任务单进程运行,不再依赖命名管道 IPC。 - 捕获的系统状态原子保存到 `%LOCALAPPDATA%\OpenSynapse`。 -- 当前实现不包含遥测、分析、自动更新、账户系统或运行时网络客户端。 +- PowerPilot 兼容默认值会在 Quiet 中按配置维护高耗电辅助进程、Armoury Crate/ASUS 服务和唤醒设备;需要保持常驻的项目应先从 `config.json` 白名单中移除。 +- 当前实现只读取本机电池、CPU、前台窗口和 Windows GPU 性能计数器遥测;不包含云分析、自动更新、账户系统或运行时网络客户端。GPU 采样在后台线程运行,电池趋势写入本地 30 秒 JSONL 历史。 安全问题请按 [SECURITY.md](SECURITY.md) 中的私密流程报告,不要创建公开 Issue。 diff --git a/ROADMAP.md b/ROADMAP.md index 68298bf..e6c6a01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,9 +12,11 @@ The roadmap describes validation gates, not delivery dates. A capability moves f ## Foundation — M0 to M3 - [x] .NET solution, Windows CI, tests, license, and project documentation. -- [x] Non-elevated WPF control panel and per-user elevated agent. -- [x] Auto, Performance, and Quiet policy selection. +- [x] PowerPilot 2.4.1-compatible elevated WinForms tray/control process with delayed per-user scheduled task. + - [x] Adapter-aware Auto, Hyper, Balance, and Quiet policy selection. - [x] Captured state and power-plan rollback. +- [x] Validated desktop editing for display policy, scaling, brightness, and refresh behavior. +- [x] Default-off, exact-allowlist Quiet wake-device control with durable verified rollback. - [x] DeathAdder V3 Pro discovery, status, DPI, and standard polling commands. - [ ] Run the reversible Windows policy smoke test on the target machine. - [ ] Run read/write verification on each claimed DeathAdder V3 Pro PID and connection role. @@ -22,9 +24,9 @@ The roadmap describes validation gates, not delivery dates. A capability moves f ## First public alpha -- Windows installation, elevation, startup, single-instance, and uninstall lifecycle. -- Removal of OpenSynapse-managed power plans during uninstall. -- Versioned state schema and durable diagnostic logs. +- Windows installation, elevation, startup, single-instance, and uninstall lifecycle (implemented; live validation pending). +- Verified removal of OpenSynapse-managed power plans during uninstall (implemented; live validation pending). +- Versioned configuration/state schemas, bounded diagnostic logs, and read-only self-test (implemented; target-Windows validation ongoing). - A documented elevated-agent threat model. - Repeatable Windows integration tests and a maintained compatibility matrix. - Signed or checksummed preview artifacts with a changelog. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e1b37f..023ab41 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,78 +1,80 @@ # OpenSynapse architecture -## Goals +## Release runtime -OpenSynapse keeps privileged work small, visible, and reversible. Device support is explicit: a product is supported only when its identity, transport, commands, and verification evidence are known. - -## Components +OpenSynapse 2.4.2 uses the PowerPilot 2.4.1 execution model because that model has already passed the target-machine installation, power-policy, display, DPI and stability test suite. ```mermaid -flowchart LR - UI["OpenSynapse.App\nWPF UI and tray"] - Core["OpenSynapse.Core\nrequests, status, policy and protocol"] - Agent["OpenSynapse.Agent\nelevated policy and HID owner"] - State["%LOCALAPPDATA%\\OpenSynapse\nversionless experimental state"] +flowchart TD + Task["Per-user scheduled task\nhighest privileges, STA, delayed logon start"] + Script["OpenSynapse.ps1\nWinForms UI, tray and automation loop"] + Native["OpenSynapse.Native.cs\ndynamically compiled native helpers"] + Config["%LOCALAPPDATA%\\OpenSynapse\nconfig, state, runtime, log and telemetry"] Windows["Windows APIs and powercfg"] - HID["Supported Razer HID control interface"] - - UI --> Core - UI -- "current-user named pipe" --> Agent - Agent --> Core - Agent --> State - Agent --> Windows - Agent --> HID + Supply["Power status and cached read-only NVIDIA evidence"] + Display["DisplayConfig, brightness, HDR and scaling"] + HID["DeathAdder V3 Pro HID feature reports"] + + Task --> Script + Script --> Config + Script --> Windows + Script --> Supply + Script --> Native + Native --> Display + Native --> HID ``` -### OpenSynapse.App +The UI and policy engine live in the same elevated per-user process. There is no WPF-to-Agent named pipe, no second startup authority and no period where the panel can be open while its backend is offline. Closing the window hides it; the tray process and automation loop continue. “Exit and restore” returns tracked power, display, wake and service state before stopping. -Runs without elevation. It displays status and sends typed requests to the agent. Closing the window hides it; explicit exit requests restoration and agent shutdown. It never writes Windows policy or HID state directly. +## Components -### OpenSynapse.Agent +### OpenSynapse.ps1 -Runs elevated for the current user. It owns mode selection, power-source reactions, state capture, restoration, Windows policy changes, device enumeration, and HID commands. The named pipe accepts only the current user and one bounded JSON request per connection. +The script is the product entry point and owns: -### OpenSynapse.Core +- installation, upgrade migration, uninstallation and the Start menu shortcut; +- the delayed highest-privilege scheduled task; +- Auto, Hyper, Balance and Quiet selection; +- supply classification, debounce and Smart Auto hysteresis; +- application rules, temporary modes and runtime health backoff; +- reversible power, brightness, HDR, scaling, refresh, wake-device and maintenance state; +- the five-page WinForms UI, custom title bar, tray menu, diagnostics and exports; +- calls into the native display, telemetry and Razer HID helpers. -Contains shared request/status models, deterministic mode selection, and device packet construction/validation. Logic that can be independent of Windows or hardware belongs here and leaves a runnable test. +Installation stops the obsolete `OpenSynapse Agent` runtime before removing it. When its executable is still present, `uninstall-cleanup` performs the original implementation's verified rollback. If only its schema-10 JSON remains, the PowerShell installer recognizes that distinct schema, directly restores the captured power/display/brightness/wake state, maps user configuration into the 2.4.1-compatible fields, and archives the legacy JSON so it cannot later be mistaken for a PowerShell runtime backup. An installed PowerPilot instance is then handed to its own restore/uninstall path; its config and state are archived first and its compatible config is promoted to OpenSynapse. Only after both prior policy engines have stopped are their old tasks/directories removed and the single OpenSynapse runtime registered. -## State transition +### OpenSynapse.Native.cs -```mermaid -stateDiagram-v2 - [*] --> Unmanaged - Unmanaged --> Captured: first policy application - Captured --> Performance: apply Performance - Captured --> Quiet: apply Quiet - Performance --> Quiet: selection or power source changes - Quiet --> Performance: selection or power source changes - Performance --> Restoring: restore or shutdown - Quiet --> Restoring: restore or shutdown - Restoring --> Unmanaged: confirmed restoration - Restoring --> Captured: any restoration remains pending -``` +Windows PowerShell 5.1 dynamically compiles this helper with `Add-Type`. It contains the native API boundaries used by the script: + +- per-monitor DPI and taskbar AppUserModelID; +- custom window dragging, dark frames and dark controls; +- CPU, battery, foreground-window, GPU and power-event telemetry; +- display mode, native dynamic refresh, scaling and Advanced Color operations; +- capability-gated DeathAdder V3 Pro HID discovery and feature reports. + +The Razer path accepts only VID `1532`, PIDs `00B6`, `00B7`, `00C2` or `00C3`, and HID Usage Page `0x0C`. DPI is limited to 100–30000; standard-receiver polling is limited to 125, 500 or 1000 Hz. Responses must match the transaction, command class, command ID and checksum. + +This is a user-mode HID feature-report implementation. It does not install a kernel driver, flash firmware or access the embedded controller. + +### Retained .NET code -The Captured State is not deleted merely because a restore was attempted. Each value is cleared only after its restoration is confirmed or it is intentionally retained for a later retry. +`OpenSynapse.sln`, `OpenSynapse.Core`, the former WPF App and the former Agent remain in the repository for protocol regression tests and migration history. They are not copied by `scripts/Publish-OpenSynapse.ps1` and are not the installed desktop runtime. -## Trust boundaries +## State and recovery -- The UI-to-agent pipe crosses a Windows integrity boundary. Access is restricted to the current user; JSON enums, sizes, ranges, operations, and device identities are validated. -- The state file is current-user writable and is not a source of arbitrary executable commands or file paths. -- HID writes require Razer VID `1532`, an explicitly supported PID, and Consumer usage page `0x0C`. -- Unknown status, response mismatch, checksum failure, and unsupported values fail closed. -- Firmware and embedded-controller writes are outside the boundary. +Configuration and rollback state are separate under `%LOCALAPPDATA%\OpenSynapse`: -## Adding device support +- `config.json` contains the persistent user selection and policy settings; +- `state.json` contains original and managed power-plan identities plus reversible wake, service, brightness and color state; +- `runtime.json` identifies the live tray process and health; +- `OpenSynapse.log` records bounded local events; +- `telemetry.jsonl` stores the rotating local telemetry history. -1. Record exact VID/PID, transport role, usage page, firmware, and connection type. -2. Establish legally shareable protocol provenance. -3. Implement pure packet construction and response validation in Core. -4. Add the smallest packet-level regression check. -5. Gate transport access on the exact capability identity. -6. Verify reads, writes, failure behavior, and rollback on owned or authorized hardware. -7. Update the public device matrix with the verified combination. +The runtime retains PowerPilot 2.4.1's atomic JSON replacement and `.bak` recovery behavior. A transient monitor failure does not switch profiles blindly: the last verified plan is preserved and monitoring backs off through 10/20/40/60-second retries. -The current DeathAdder path remains direct. A general provider or plugin abstraction should be introduced only when a second maintained driver demonstrates a real common interface. +## Test boundary -## Prototype boundary +`scripts/Test-Milestones.ps1` runs the non-destructive definition and telemetry suite. `-AdminRelease` runs the inherited reversible administrator suite, including installation and power-plan round trips. `-TestMouseWrites` writes the mouse's currently reported DPI and polling values back to the same supported device. -The native display helper migrated from PowerPilot now lives under `src/OpenSynapse.Agent/Windows/`. Prototype and protocol references under ignored `ref/` are evidence only and must never be build dependencies. +Packet construction is testable without hardware. A hardware support claim still requires a connected target device and successful read/write verification. diff --git a/docs/POWERPILOT_PARITY_REPORT.md b/docs/POWERPILOT_PARITY_REPORT.md new file mode 100644 index 0000000..9189376 --- /dev/null +++ b/docs/POWERPILOT_PARITY_REPORT.md @@ -0,0 +1,69 @@ +# OpenSynapse 与 PowerPilot 2.4.1 重新移植报告 + +检查分支:`dev-echo` +参考原型:`ref/PowerPilot2.4.1` + +## 结论 + +上一版 OpenSynapse 把 PowerPilot 2.4.1 的 PowerShell/WinForms 单进程架构重写成了 WPF UI + 独立提权 Agent + 命名管道。虽然核心算法被分别实现,但启动、提权、IPC、状态所有权和 UI 生命周期发生了根本变化,现场出现了 `Agent unavailable`、`Agent offline`、UAC 取消以及 IPC 访问错误,因此不能视为等价移植。 + +本次重新移植不再延续这条分叉:发布版直接以 PowerPilot 2.4.1 的脚本、原生助手、五页 UI、计划任务、安装/卸载、Smart Auto 和恢复模型为底座,完成全量 OpenSynapse 重命名,并合入 DeathAdder V3 Pro HID 功能。 + +## 当前发布架构 + +| 项目 | 当前实现 | +| --- | --- | +| 产品入口 | `OpenSynapse.ps1` | +| UI | PowerPilot 2.4.1 WinForms 五页深色 UI,产品名改为 OpenSynapse | +| 后台 | 与 UI 同进程的托盘和 5 秒自动化循环 | +| 提权 | 当前用户最高权限计划任务,登录延迟 30 秒 | +| IPC | 不需要;已移除 WPF/Agent 命名管道故障点 | +| 原生能力 | `OpenSynapse.Native.cs` 由 Windows PowerShell 5.1 动态编译 | +| 数据目录 | `%LOCALAPPDATA%\OpenSynapse` | +| 安装目录 | `%ProgramFiles%\OpenSynapse` | +| 任务名 | `OpenSynapse` | +| AppUserModelID | `OpenSynapse.Desktop` | + +## PowerPilot 2.4.1 功能移植状态 + +以下能力直接来自并保留 2.4.1 的实现: + +- Auto / Hyper / Balance / Quiet; +- 280W、USB-C PD、电池和未知交流供电分类及防抖; +- CPU 最小/最大状态、EPP、Boost、核心停放、PCIe、Wi-Fi、USB、睡眠和待机策略; +- Smart Auto 的 CPU/GPU、前台、全屏、应用规则、迟滞和最短驻留; +- 临时模式和供电变化结束条件; +- HDR、亮度、缩放、固定/动态刷新率; +- Quiet 进程、服务与唤醒设备维护; +- 电池 Class API、GPU/DXGI、dGPU 活动、遥测历史和诊断导出; +- 原子配置、回滚状态、运行健康、错误退避; +- 五页 UI、自定义标题栏拖动、任务栏身份、托盘和开始菜单快捷方式; +- 安装、覆盖升级、计划任务、卸载恢复和管理员发布测试。 + +## 合入的雷蛇鼠标功能 + +OpenSynapse 新增了与上述单进程运行时兼容的 `OpenSynapseNative.RazerMouse`: + +- 只枚举 Razer VID `1532`; +- 只允许 DeathAdder V3 Pro PID `00B6`、`00B7`、`00C2`、`00C3`; +- 只接受 HID Usage Page `0x0C` 控制接口; +- 读取产品名、连接类型、固件、序列号、DPI、轮询率、电量和充电状态; +- 写入 100–30000 DPI; +- 写入标准接收器 125/500/1000 Hz; +- 校验响应状态、事务 ID、命令类、命令 ID 和 XOR 校验和; +- 在 Settings 页显示设备状态并提供 DPI、轮询率和刷新操作; +- 在 Diagnostics 页输出鼠标证据。 + +该能力使用 Windows 自带 HID 用户态 feature report,不安装或替换内核驱动,不写固件、EC、风扇、TGP 或 MUX。 + +## 迁移处理 + +安装新版时会先停止旧的 `OpenSynapse Agent` 计划任务以及 `OpenSynapse.Agent` / `OpenSynapse.App` 进程。如果旧 Agent 可执行文件仍存在,安装器先调用其 `uninstall-cleanup` 完成原实现的恢复;如果二进制已经缺失,则由新运行时直接恢复旧 schema-10 状态中的原电源计划、HDR、显示缩放、亮度和唤醒权限。随后若检测到已安装的 PowerPilot,先把其配置和恢复状态归档到 OpenSynapse 数据目录,再调用 PowerPilot 自身的恢复/卸载入口,确认旧任务和运行时消失后把兼容配置提升为 OpenSynapse 配置。最后才清理旧目录并注册唯一的单进程 `OpenSynapse` 任务,防止两个策略引擎同时控制系统。 + +## 验证边界 + +自动测试可以证明脚本可解析、原生 C# 可编译、功能定义完整,以及 Razer DPI/轮询报告构造和校验和正确。真实 DPI、轮询率、固件和电量回读仍需要连接受支持的 DeathAdder V3 Pro;未连接设备时必须显示“未检测到设备”,不能把它记为硬件通过。 + +2026-07-25 的真实安装验证已确认:`OpenSynapse` 是唯一运行的策略任务,状态为 Running/Highest/Interactive,运行时为 Healthy/PerMonitorV2,Auto 在 155 W HighPowerAC 下处于合法的 Balance 档位,78 个电源参数、快捷方式、图标、AppUserModelID 和发布包哈希全部通过。旧 PowerPilot 的 21 条应用规则已保留。 + +本机枚举到的 Razer PID `02C6` 属于 Razer Blade 16(2025)内置键盘,而不是 DeathAdder 控制接口;因此鼠标发现为 0 是正确的白名单结果。本轮未声称真实鼠标写入通过。 diff --git a/scripts/Install-OpenSynapse.ps1 b/scripts/Install-OpenSynapse.ps1 new file mode 100644 index 0000000..a0161cd --- /dev/null +++ b/scripts/Install-OpenSynapse.ps1 @@ -0,0 +1,14 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$published = Join-Path $root 'artifacts\publish\OpenSynapse\OpenSynapse.ps1' +$source = Join-Path $root 'src\OpenSynapse.PowerShell\OpenSynapse.ps1' +$mainScript = if (Test-Path -LiteralPath $published -PathType Leaf) { $published } else { $source } +if (-not (Test-Path -LiteralPath $mainScript -PathType Leaf)) { + throw 'OpenSynapse package was not found. Run scripts\Publish-OpenSynapse.ps1 first.' +} + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $mainScript -Mode Install +exit $LASTEXITCODE diff --git a/scripts/Publish-OpenSynapse.ps1 b/scripts/Publish-OpenSynapse.ps1 new file mode 100644 index 0000000..70f3b3c --- /dev/null +++ b/scripts/Publish-OpenSynapse.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param( + [ValidateSet('win-x64', 'win-arm64')][string]$Runtime = 'win-x64', + [switch]$FrameworkDependent, + [string]$DotnetPath +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$source = Join-Path $root 'src\OpenSynapse.PowerShell' +$artifacts = [IO.Path]::GetFullPath((Join-Path $root 'artifacts')) +$publishRoot = [IO.Path]::GetFullPath((Join-Path $artifacts 'publish')) +$output = [IO.Path]::GetFullPath((Join-Path $publishRoot 'OpenSynapse')) +$zipPath = [IO.Path]::GetFullPath((Join-Path $artifacts 'OpenSynapse-2.4.2.zip')) +$separator = [IO.Path]::DirectorySeparatorChar +$artifactsPrefix = $artifacts.TrimEnd($separator, [IO.Path]::AltDirectorySeparatorChar) + $separator +if (-not $output.StartsWith($artifactsPrefix, [StringComparison]::OrdinalIgnoreCase) -or + -not $zipPath.StartsWith($artifactsPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Resolved publish output is outside the repository artifacts directory.' +} +if (-not (Test-Path -LiteralPath $source -PathType Container)) { + throw "OpenSynapse PowerShell source was not found: $source" +} + +[IO.Directory]::CreateDirectory($publishRoot) | Out-Null +foreach ($legacyDirectory in @( + (Join-Path $publishRoot 'Agent'), + (Join-Path $publishRoot 'App'), + $output +)) { + if (Test-Path -LiteralPath $legacyDirectory) { + Remove-Item -LiteralPath $legacyDirectory -Recurse -Force + } +} +[IO.Directory]::CreateDirectory($output) | Out-Null + +foreach ($fileName in @( + 'OpenSynapse.ps1', + 'OpenSynapse.Native.cs', + 'Install-OpenSynapse.cmd', + 'Uninstall-OpenSynapse.cmd', + 'README.md', + 'CHANGELOG.md', + 'TEST-REPORT.md' +)) { + Copy-Item -LiteralPath (Join-Path $source $fileName) -Destination (Join-Path $output $fileName) +} +Copy-Item -LiteralPath (Join-Path $source 'assets') -Destination (Join-Path $output 'assets') -Recurse +Copy-Item -LiteralPath (Join-Path $source 'tests') -Destination (Join-Path $output 'tests') -Recurse + +$publishedScript = Join-Path $output 'OpenSynapse.ps1' +$publishedNative = Join-Path $output 'OpenSynapse.Native.cs' +$parseErrors = $null +[void][Management.Automation.Language.Parser]::ParseFile($publishedScript, [ref]$null, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { + throw "Published script contains parser errors: $($parseErrors.Message -join '; ')" +} + +$compileCommand = "& { `$ErrorActionPreference = 'Stop'; Add-Type -Path '$($publishedNative.Replace("'", "''"))' }" +& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $compileCommand +if ($LASTEXITCODE -ne 0) { throw "Published native helper compilation failed with exit code $LASTEXITCODE." } + +if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } +Compress-Archive -Path (Join-Path $output '*') -DestinationPath $zipPath -CompressionLevel Optimal + +Write-Host "OpenSynapse 2.4.2 package: $output" +Write-Host "OpenSynapse 2.4.2 archive: $zipPath" diff --git a/scripts/Test-InstallerDefinitions.ps1 b/scripts/Test-InstallerDefinitions.ps1 new file mode 100644 index 0000000..6fe0ff1 --- /dev/null +++ b/scripts/Test-InstallerDefinitions.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$mainPath = Join-Path $root 'src\OpenSynapse.PowerShell\OpenSynapse.ps1' +$nativePath = Join-Path $root 'src\OpenSynapse.PowerShell\OpenSynapse.Native.cs' +$publishPath = Join-Path $PSScriptRoot 'Publish-OpenSynapse.ps1' +$installPath = Join-Path $PSScriptRoot 'Install-OpenSynapse.ps1' +$uninstallPath = Join-Path $PSScriptRoot 'Uninstall-OpenSynapse.ps1' + +foreach ($path in @($mainPath, $nativePath, $publishPath, $installPath, $uninstallPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing installer input: $path" } +} + +$parseErrors = $null +[void][Management.Automation.Language.Parser]::ParseFile($mainPath, [ref]$null, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "Main script parser errors: $($parseErrors.Message -join '; ')" } + +$mainSource = Get-Content -LiteralPath $mainPath -Raw -Encoding UTF8 +$publishSource = Get-Content -LiteralPath $publishPath -Raw -Encoding UTF8 +$installSource = Get-Content -LiteralPath $installPath -Raw -Encoding UTF8 +$uninstallSource = Get-Content -LiteralPath $uninstallPath -Raw -Encoding UTF8 +foreach ($required in @( + "`$script:TaskName = 'OpenSynapse'", + "`$script:LegacyAgentTaskName = 'OpenSynapse Agent'", + 'function Register-OpenSynapseTask', + 'RunLevel Highest', + '-ExecutionTimeLimit ([TimeSpan]::Zero)', + '-WindowStyle Hidden -STA', + 'function Remove-LegacyDotNetRuntime', + 'function Remove-LegacyPowerPilotRuntime', + 'PowerPilot-2.4.1-migration-', + '-File $powerPilotScript -Mode Uninstall', + "ValidateSet('Run', 'Open', 'Install', 'Uninstall', 'Status', 'Apply', 'SelfTest')" +)) { + if ($mainSource.IndexOf($required, [StringComparison]::Ordinal) -lt 0) { + throw "Installer runtime definition is missing: $required" + } +} +foreach ($required in @('src\OpenSynapse.PowerShell', 'OpenSynapse-2.4.2.zip', 'Compress-Archive')) { + if ($publishSource.IndexOf($required, [StringComparison]::Ordinal) -lt 0) { + throw "Publisher definition is missing: $required" + } +} +if ($installSource.IndexOf('-Mode Install', [StringComparison]::Ordinal) -lt 0) { + throw 'Installer wrapper does not invoke OpenSynapse Install mode.' +} +if ($uninstallSource.IndexOf('-Mode Uninstall', [StringComparison]::Ordinal) -lt 0) { + throw 'Uninstaller wrapper does not invoke OpenSynapse Uninstall mode.' +} + +$ErrorActionPreference = 'Stop' +Add-Type -Path $nativePath +if ('SetDpi' -notin [OpenSynapseNative.RazerMouse].GetMethods().Name) { + throw 'Published native helper does not include Razer mouse control.' +} + +Write-Host 'OpenSynapse PowerShell installer definitions passed.' diff --git a/scripts/Test-Milestones.ps1 b/scripts/Test-Milestones.ps1 index a1a49b9..6889cef 100644 --- a/scripts/Test-Milestones.ps1 +++ b/scripts/Test-Milestones.ps1 @@ -1,108 +1,70 @@ [CmdletBinding()] -param([switch]$TestMouseWrites) +param( + [switch]$TestMouseWrites, + [switch]$AdminRelease +) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -$agent = Join-Path $root 'src\OpenSynapse.Agent\bin\Debug\net10.0-windows\OpenSynapse.Agent.dll' +$project = Join-Path $root 'src\OpenSynapse.PowerShell' +$tests = Join-Path $project 'tests' +$resultDirectory = Join-Path $root 'artifacts\test-results\PowerShell' +[IO.Directory]::CreateDirectory($resultDirectory) | Out-Null -$identity = [Security.Principal.WindowsIdentity]::GetCurrent() -$principal = [Security.Principal.WindowsPrincipal]::new($identity) -if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw 'Run this smoke test from an elevated PowerShell terminal.' -} - -dotnet build (Join-Path $root 'OpenSynapse.sln') -if ($LASTEXITCODE -ne 0) { throw 'Build failed.' } +$definitionTests = @( + 'Test-ConfigMigration.ps1', + 'Test-IconAssets.ps1', + 'Test-TaskbarIdentity.ps1', + 'Test-DarkThemeDefinition.ps1', + 'Test-RazerMouseDefinition.ps1', + 'Test-SynapseShellDefinition.ps1', + 'Test-SmartAutomation.ps1', + 'Test-AutomationGuards.ps1', + 'Test-SafeTelemetryAutomation.ps1', + 'Test-BatteryTelemetryTrend.ps1', + 'Test-StabilityExperience.ps1', + 'Test-PowerPolicyDefinition.ps1', + 'Test-HyperPerformance.ps1', + 'Test-QuietEndurance.ps1', + 'Test-QuietControlSemantics.ps1', + 'Test-SupplyRefreshDefinition.ps1', + 'Test-SupplyDebounce.ps1', + 'Test-PowerEventCoalescing.ps1', + 'Test-ScheduledTaskDelayDefinition.ps1', + 'Test-ThirdPartyAutostartIsolation.ps1', + 'Test-SnipasteAutostartMigration.ps1' +) -function Invoke-Agent { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - $json = & dotnet $agent @Arguments - if ($LASTEXITCODE -ne 0) { throw ($json -join [Environment]::NewLine) } - return ($json -join [Environment]::NewLine) | ConvertFrom-Json +foreach ($testName in $definitionTests) { + $testPath = Join-Path $tests $testName + $resultPath = Join-Path $resultDirectory ([IO.Path]::GetFileNameWithoutExtension($testName) + '.json') + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testPath -ResultPath $resultPath + if ($LASTEXITCODE -ne 0) { throw "$testName failed with exit code $LASTEXITCODE." } + $record = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + if ([string]$record.Result -ne 'PASS') { throw "$testName failed: $($record.Message)" } } -function Invoke-AgentPipe { - param([string]$Operation, [int]$ConnectTimeout = 1000) - $pipe = [IO.Pipes.NamedPipeClientStream]::new('.', 'OpenSynapse.Agent', [IO.Pipes.PipeDirection]::InOut) - try { - $pipe.Connect($ConnectTimeout) - $writer = [IO.StreamWriter]::new($pipe) - $reader = [IO.StreamReader]::new($pipe) - $writer.AutoFlush = $true - $writer.WriteLine((@{ operation = $Operation } | ConvertTo-Json -Compress)) - $read = $reader.ReadLineAsync() - if (-not $read.Wait(5000)) { throw 'OpenSynapse.Agent pipe response timed out.' } - if ($null -eq $read.Result) { throw 'OpenSynapse.Agent closed the pipe without a response.' } - return $read.Result | ConvertFrom-Json +if ($TestMouseWrites) { + $nativePath = Join-Path $project 'OpenSynapse.Native.cs' + Add-Type -Path $nativePath + $mouse = @([OpenSynapseNative.RazerMouse]::GetDevices()) | Select-Object -First 1 + if ($null -eq $mouse -or $null -eq $mouse.DpiX -or $null -eq $mouse.PollingRate) { + throw 'A readable DeathAdder V3 Pro is required for mouse write verification.' } - finally { $pipe.Dispose() } + [OpenSynapseNative.RazerMouse]::SetDpi([int]$mouse.DpiX, [int]$mouse.DpiY) + [OpenSynapseNative.RazerMouse]::SetPollingRate([int]$mouse.PollingRate) + Write-Host "Razer mouse write round-trip passed: $($mouse.DpiX)x$($mouse.DpiY) DPI, $($mouse.PollingRate) Hz." } -$originalText = & powercfg /getactivescheme -$originalGuid = [regex]::Match(($originalText | Out-String), '[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}').Value -if (-not $originalGuid) { throw 'Cannot read the original Windows power plan.' } - -function Assert-Restored { - $restoredText = & powercfg /getactivescheme - $restoredGuid = [regex]::Match(($restoredText | Out-String), '[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}').Value - if (-not [string]::Equals($originalGuid, $restoredGuid, [StringComparison]::OrdinalIgnoreCase)) { - throw "Power plan rollback failed: expected $originalGuid, got $restoredGuid." - } - $state = Get-Content (Join-Path $env:LOCALAPPDATA 'OpenSynapse\state.json') -Raw | ConvertFrom-Json - if ($null -ne $state.OriginalPowerPlan) { throw 'Restored power plan snapshot was not cleared.' } -} - -try { - $status = Invoke-Agent status - if (-not $status.Success) { throw $status.Message } - - $performance = Invoke-Agent apply Performance - if (-not $performance.Success -or $performance.Status.ActiveMode -ne 'Performance') { - throw 'Performance mode verification failed.' - } - - $quiet = Invoke-Agent apply Quiet - if (-not $quiet.Success -or $quiet.Status.ActiveMode -ne 'Quiet') { - throw 'Quiet mode verification failed.' - } - - if ($TestMouseWrites) { - $mouse = $quiet.Status.RazerDevices | Select-Object -First 1 - if ($null -eq $mouse -or $null -eq $mouse.DpiX -or $null -eq $mouse.PollingRate) { - throw 'A readable DeathAdder V3 Pro is required for mouse write verification.' - } - $dpi = Invoke-Agent mouse-dpi ([string]$mouse.DpiX) - $polling = Invoke-Agent mouse-polling ([string]$mouse.PollingRate) - if (-not $dpi.Success -or -not $polling.Success) { throw 'DeathAdder write verification failed.' } - } -} -finally { - $restore = Invoke-Agent restore - Assert-Restored -} - -$server = $null -try { - $server = Start-Process -FilePath dotnet -ArgumentList ('"{0}" serve' -f $agent) -WindowStyle Hidden -PassThru - $pipeStatus = $null - for ($attempt = 0; $attempt -lt 10 -and $null -eq $pipeStatus; $attempt++) { - try { $pipeStatus = Invoke-AgentPipe Status } - catch { - if ($server.HasExited) { throw 'OpenSynapse.Agent exited before accepting a pipe connection.' } - Start-Sleep -Milliseconds 500 - } - } - if ($null -eq $pipeStatus -or -not $pipeStatus.Success) { throw 'Named-pipe status verification failed.' } - $shutdown = Invoke-AgentPipe Shutdown 3000 - if (-not $shutdown.Success) { throw $shutdown.Message } - if (-not $server.WaitForExit(5000)) { throw 'OpenSynapse.Agent did not exit after Shutdown.' } - Assert-Restored -} -finally { - if ($null -ne $server -and -not $server.HasExited) { - try { $restore = Invoke-Agent restore } catch { } - Stop-Process -Id $server.Id -Force +if ($AdminRelease) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run -AdminRelease from an elevated PowerShell terminal.' } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $tests 'Run-AdminReleaseTests.ps1') ` + -ResultDirectory (Join-Path $resultDirectory 'admin') + if ($LASTEXITCODE -ne 0) { throw "Administrator release tests failed with exit code $LASTEXITCODE." } } -Write-Host 'M0-M3 Windows smoke test passed.' -ForegroundColor Green +Write-Host "OpenSynapse PowerShell milestone tests passed: $($definitionTests.Count) definition/runtime tests." -ForegroundColor Green diff --git a/scripts/Uninstall-OpenSynapse.ps1 b/scripts/Uninstall-OpenSynapse.ps1 new file mode 100644 index 0000000..d9a559b --- /dev/null +++ b/scripts/Uninstall-OpenSynapse.ps1 @@ -0,0 +1,14 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$installed = Join-Path $env:ProgramFiles 'OpenSynapse\OpenSynapse.ps1' +$root = Split-Path -Parent $PSScriptRoot +$source = Join-Path $root 'src\OpenSynapse.PowerShell\OpenSynapse.ps1' +$mainScript = if (Test-Path -LiteralPath $installed -PathType Leaf) { $installed } else { $source } +if (-not (Test-Path -LiteralPath $mainScript -PathType Leaf)) { + throw 'OpenSynapse installation or source package was not found.' +} + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $mainScript -Mode Uninstall +exit $LASTEXITCODE diff --git a/src/OpenSynapse.Agent/AgentController.cs b/src/OpenSynapse.Agent/AgentController.cs index eac9172..f20cd4d 100644 --- a/src/OpenSynapse.Agent/AgentController.cs +++ b/src/OpenSynapse.Agent/AgentController.cs @@ -1,4 +1,5 @@ using System.Security.Principal; +using System.Text.Json; using OpenSynapse.Core; namespace OpenSynapse.Agent; @@ -7,53 +8,245 @@ internal sealed class AgentController { private readonly object gate = new(); private readonly StateStore store = new(); + private readonly ConfigurationStore configurationStore = new(); + private readonly AgentLog log = new(); private readonly PowerPlanManager power = new(); + private readonly PowerSupplyProbe powerSupply = new(); private readonly DisplayPolicy displays = new(); + private readonly WakeDeviceManager wakeDevices = new(); private readonly DeathAdderHid deathAdder = new(); + private readonly TelemetryProbe telemetry = new(); + private readonly TelemetryHistoryWriter telemetryHistory = new(); + private RuntimeHealth health = RuntimeHealth.Starting; + private DateTimeOffset lastRuntimeWrite = DateTimeOffset.MinValue; + private int consecutiveFailures; private bool shuttingDown; + public void Dispose() => telemetry.Dispose(); + public Task HandleAsync(AgentRequest request) { lock (gate) { try { return Task.FromResult(Handle(request)); } - catch (Exception ex) { return Task.FromResult(new AgentResponse(false, ex.Message)); } + catch (Exception ex) + { + _ = log.TryWrite($"request.{request.Operation}.failed", ex.Message); + return Task.FromResult(new AgentResponse(false, ex.Message)); + } } } - public void ApplyCurrentSelection() + public bool ApplyCurrentSelection(bool force = false) { lock (gate) { - if (shuttingDown) return; + if (shuttingDown) return false; try { - var state = store.Load(); - ApplyMode(ModeSelector.Resolve(state.Selection, GetPowerSource()), state); + var (state, config) = LoadContext(); + var powerSnapshot = powerSupply.GetSnapshot(); + if (config.Selection == ModeSelection.Balanced + && !ModeSelector.IsBalancedEligible(powerSnapshot, config.BalancedBatteryThresholdPercent)) + { + config.Selection = ModeSelection.Quiet; + configurationStore.Save(config); + _ = log.TryWrite( + "selection.latched", + $"Balanced changed to Quiet below {config.BalancedBatteryThresholdPercent}% battery."); + } + var telemetrySnapshot = telemetry.Read(config.ApplicationRules.Any(rule => rule.Scope == ApplicationRuleScope.Running)); + UpdateDgpuDiagnostics(state, config, powerSnapshot, telemetrySnapshot); + var desiredMode = ResolveDesiredMode(state, config, powerSnapshot, telemetrySnapshot); + if (!force && state.ActiveMode == desiredMode) + { + wakeDevices.Apply(desiredMode, config, state, () => store.Save(state)); + health = RuntimeHealth.Healthy; + consecutiveFailures = 0; + WriteRuntime(); + store.Save(state); + _ = telemetryHistory.TryWrite(state, config, powerSnapshot, telemetrySnapshot); + return true; + } + ApplyMode(desiredMode, state, config, powerSnapshot); + health = RuntimeHealth.Healthy; + consecutiveFailures = 0; + WriteRuntime(); + store.Save(state); + _ = telemetryHistory.TryWrite(state, config, powerSnapshot, telemetrySnapshot); + return true; } catch (Exception ex) { + health = RuntimeHealth.Recovering; + consecutiveFailures++; + WriteRuntime(force: true); + _ = log.TryWrite("selection.automatic.failed", ex.Message); Console.Error.WriteLine($"Automatic mode application failed: {ex.Message}"); + return false; + } + } + } + + public void ReapplyDisplayPolicy() + { + lock (gate) + { + if (shuttingDown) return; + OpenSynapseState? state = null; + try + { + var context = LoadContext(); + state = context.State; + if (state.ActiveMode is not OperatingMode mode) return; + RequireAdministrator(); + displays.Capture(mode, state, context.Config); + store.Save(state); + displays.Apply(mode, state, context.Config, powerSnapshot: powerSupply.GetSnapshot()); + _ = log.TryWrite("display.reapplied", mode.ToString()); + } + catch (Exception ex) + { + _ = log.TryWrite("display.reapply.failed", ex.Message); + Console.Error.WriteLine($"Display policy reapplication failed: {ex.Message}"); + } + finally + { + if (state is not null) + { + try { store.Save(state); } + catch (Exception ex) { _ = log.TryWrite("display.state-save.failed", ex.Message); } + } } } } private AgentResponse Handle(AgentRequest request) { - var state = store.Load(); + if (request.Operation == AgentOperation.SelfTest) return RunSelfTest(); + var (state, config) = LoadContext(); switch (request.Operation) { case AgentOperation.Status: case AgentOperation.ListDevices: - return new AgentResponse(true, "OK", GetStatus(state)); + return new AgentResponse(true, "OK", GetStatus(state, config)); case AgentOperation.Apply: - return ApplyMode(request.Mode ?? throw new ArgumentException("Mode is required."), state); + var mode = request.Mode ?? throw new ArgumentException("Mode is required."); + var applyPowerSnapshot = powerSupply.GetSnapshot(); + EnsureBalancedEligible(mode, applyPowerSnapshot, config); + return ApplyMode(mode, state, config, applyPowerSnapshot); case AgentOperation.SetSelection: - state.Selection = request.Selection ?? throw new ArgumentException("Selection is required."); + var selection = request.Selection ?? throw new ArgumentException("Selection is required."); + var powerSnapshot = powerSupply.GetSnapshot(); + if (selection == ModeSelection.Balanced + && !ModeSelector.IsBalancedEligible(powerSnapshot, config.BalancedBatteryThresholdPercent)) + throw new InvalidOperationException( + $"Balanced mode requires at least {config.BalancedBatteryThresholdPercent}% battery; " + + $"current charge is {powerSnapshot.BatteryPercent?.ToString() ?? "unavailable"}%."); + config.Selection = selection; + configurationStore.Save(config); + _ = log.TryWrite("selection.changed", selection.ToString()); + return ApplyMode( + ModeSelector.Resolve(selection, powerSnapshot, config.BalancedBatteryThresholdPercent), + state, + config, + powerSnapshot); + + case AgentOperation.SetDisplayPolicy: + RequireAdministrator(); + var displaySettings = request.DisplayPolicy + ?? throw new ArgumentException("DisplayPolicy is required."); + var updatedConfig = config.WithDisplayPolicy(displaySettings); + var existingDisplayRestored = displays.Restore(state); + if (!existingDisplayRestored) + throw new InvalidOperationException( + "Existing display state restoration remains pending; settings were not changed."); + state.ActiveMode = null; store.Save(state); - return ApplyMode(ModeSelector.Resolve(state.Selection, GetPowerSource()), state); + configurationStore.Save(updatedConfig); + var updatedPowerSnapshot = powerSupply.GetSnapshot(); + var updatedMode = ModeSelector.Resolve( + updatedConfig.Selection, + updatedPowerSnapshot, + updatedConfig.BalancedBatteryThresholdPercent); + _ = log.TryWrite("configuration.display.changed", updatedConfig.RefreshPolicy.ToString()); + return ApplyMode(updatedMode, state, updatedConfig, updatedPowerSnapshot); + + case AgentOperation.SetQuietMaintenance: + RequireAdministrator(); + var quietSettings = request.QuietMaintenance + ?? throw new ArgumentException("QuietMaintenance is required."); + var updatedQuietConfig = config.WithQuietMaintenance(quietSettings); + wakeDevices.Restore(state, () => store.Save(state)); + configurationStore.Save(updatedQuietConfig); + if (state.ActiveMode == OperatingMode.Quiet) + wakeDevices.Apply(OperatingMode.Quiet, updatedQuietConfig, state, () => store.Save(state)); + _ = log.TryWrite( + "configuration.quiet-maintenance.changed", + updatedQuietConfig.ManageWakeDevices + ? $"Enabled for {updatedQuietConfig.QuietWakeDeviceNames.Count} exact device name(s)." + : "Disabled."); + return new AgentResponse( + true, + "Quiet wake-device settings saved and reconciled.", + GetStatus(state, updatedQuietConfig)); + + case AgentOperation.SetApplicationRules: + var rules = request.ApplicationRules + ?? throw new ArgumentException("ApplicationRules are required."); + var updatedRulesConfig = config.WithApplicationRules(rules); + configurationStore.Save(updatedRulesConfig); + _ = log.TryWrite("configuration.application-rules.changed", $"{rules.Count} rule(s)."); + return new AgentResponse(true, "Application rules saved.", GetStatus(state, updatedRulesConfig)); + + case AgentOperation.SetTemporaryMode: + RequireAdministrator(); + var temporaryMode = request.Mode ?? throw new ArgumentException("Mode is required."); + var temporaryPower = powerSupply.GetSnapshot(); + EnsureBalancedEligible(temporaryMode, temporaryPower, config); + if (!request.TemporaryUntilPowerChange && request.TemporaryMinutes is not (30 or 60 or 120)) + throw new ArgumentException("Temporary mode duration must be 30, 60, 120 minutes, or until power changes."); + state.TemporaryMode = new TemporaryModeState( + temporaryMode, + request.TemporaryUntilPowerChange + ? null + : DateTimeOffset.UtcNow.AddMinutes(request.TemporaryMinutes!.Value), + request.TemporaryUntilPowerChange, + temporaryPower.SupplyType); + store.Save(state); + return ApplyMode(temporaryMode, state, config, temporaryPower); + + case AgentOperation.ClearTemporaryMode: + RequireAdministrator(); + state.TemporaryMode = null; + store.Save(state); + var resumedPower = powerSupply.GetSnapshot(); + var resumedMode = ResolveDesiredMode(state, config, resumedPower, telemetry.Read()); + return ApplyMode(resumedMode, state, config, resumedPower); + + case AgentOperation.ApplyDisplayPolicyNow: + RequireAdministrator(); + var explicitMode = state.ActiveMode + ?? ResolveDesiredMode(state, config, powerSupply.GetSnapshot(), telemetry.Read()); + displays.Capture(explicitMode, state, config); + store.Save(state); + displays.Apply(explicitMode, state, config, applyDisplaySettings: true); + _ = log.TryWrite("display.explicit-apply", explicitMode.ToString()); + return new AgentResponse(true, "Display policy applied. The display link may blink briefly.", GetStatus(state, config)); + + case AgentOperation.ExportDiagnostics: + var exportTelemetry = telemetry.Read(config.ApplicationRules.Any(rule => rule.Scope == ApplicationRuleScope.Running)); + var exportPath = DiagnosticExporter.Export( + config, + state, + powerSupply, + log.FilePath, + exportTelemetry, + telemetryHistory.FilePath); + _ = log.TryWrite("diagnostics.exported", exportPath); + return new AgentResponse(true, $"Diagnostics exported to {exportPath}.", GetStatus(state, config) with { DiagnosticsPath = exportPath }); case AgentOperation.Restore: case AgentOperation.Shutdown: @@ -61,13 +254,17 @@ private AgentResponse Handle(AgentRequest request) shuttingDown = request.Operation == AgentOperation.Shutdown; try { - displays.Restore(state); + var displayRestored = displays.Restore(state); power.Restore(state); + wakeDevices.Restore(state, () => store.Save(state)); + if (!displayRestored) + throw new InvalidOperationException("Display state restoration remains pending; captured state was retained for retry."); state.ActiveMode = null; var message = request.Operation == AgentOperation.Shutdown - ? "Restored captured Windows state; agent is shutting down." - : "Restored captured Windows state."; - return new AgentResponse(true, message, GetStatus(state)); + ? "Restored captured Windows and wake state; agent is shutting down." + : "Restored captured Windows and wake state."; + _ = log.TryWrite("state.restored", message); + return new AgentResponse(true, message, GetStatus(state, config)); } catch { @@ -76,58 +273,446 @@ private AgentResponse Handle(AgentRequest request) } finally { store.Save(state); } + case AgentOperation.UninstallCleanup: + RequireAdministrator(); + try + { + var displayRestored = displays.Restore(state); + power.Restore(state); + wakeDevices.Restore(state, () => store.Save(state)); + if (!displayRestored) + throw new InvalidOperationException( + "Display restoration remains pending; uninstall cleanup was stopped for a later retry."); + state.ActiveMode = null; + power.DeleteManagedPlans(state); + _ = log.TryWrite( + "uninstall.cleanup", + "Restored captured state and wake permissions, then removed managed power plans."); + return new AgentResponse( + true, + "Restored captured state and wake permissions, then removed managed power plans.", + GetStatus(state, config)); + } + finally { store.Save(state); } + case AgentOperation.SetMouseDpi: deathAdder.SetDpi( request.DpiX ?? throw new ArgumentException("DpiX is required."), request.DpiY ?? request.DpiX.Value, request.ProductId); - return new AgentResponse(true, "DeathAdder DPI updated.", GetStatus(state)); + return new AgentResponse(true, "DeathAdder DPI updated.", GetStatus(state, config)); case AgentOperation.SetMousePollingRate: deathAdder.SetPollingRate( request.PollingRate ?? throw new ArgumentException("PollingRate is required."), request.ProductId); - return new AgentResponse(true, "DeathAdder polling rate updated.", GetStatus(state)); + return new AgentResponse(true, "DeathAdder polling rate updated.", GetStatus(state, config)); default: throw new ArgumentOutOfRangeException(nameof(request.Operation)); } } - private AgentStatus GetStatus(OpenSynapseState state) => new( - power.GetActiveGuid(), - state.ActiveMode, - state.Selection, - GetPowerSource(), - deathAdder.ReadDevices()); + private AgentStatus GetStatus( + OpenSynapseState state, + OpenSynapseConfig config, + PowerSnapshot? powerSnapshot = null) + { + powerSnapshot ??= powerSupply.GetSnapshot(); + var telemetrySnapshot = telemetry.Read(config.ApplicationRules.Any(rule => rule.Scope == ApplicationRuleScope.Running)); + telemetrySnapshot = telemetrySnapshot with + { + DgpuActivitySuspected = state.SmartAutomation.DgpuActivitySuspected, + DgpuActivityConfidence = state.SmartAutomation.DgpuActivityConfidence + }; + return new AgentStatus( + power.GetActiveGuid(), + state.ActiveMode, + config.Selection, + powerSnapshot.Source, + powerSnapshot.SupplyType, + powerSnapshot.BatteryPercent, + powerSnapshot.AdapterLimitWatts, + deathAdder.ReadDevices(), + config.ToDisplayPolicySettings(), + config.ToQuietMaintenanceSettings(), + GetWakeDevicesForStatus(), + GetSmartStatus(state), + telemetrySnapshot, + state.TemporaryMode, + health, + null, + config.ApplicationRules); + } - private AgentResponse ApplyMode(OperatingMode mode, OpenSynapseState state) + private AgentResponse ApplyMode( + OperatingMode mode, + OpenSynapseState state, + OpenSynapseConfig config, + PowerSnapshot? powerSnapshot = null) { RequireAdministrator(); try { state.OriginalPowerPlan ??= power.GetActiveGuid(); - if (mode == OperatingMode.Quiet) displays.CaptureForQuiet(state); + var applyDisplay = !config.SeamlessModeSwitching; + if (applyDisplay) displays.Capture(mode, state, config); store.Save(state); power.Apply(mode, state); - displays.Apply(mode, state); + if (mode == OperatingMode.Performance) + power.ApplyHyperCpuPolicy(state, config.HyperCpuPolicy); + if (mode == OperatingMode.Quiet && config.AdaptiveQuietCpu && powerSnapshot?.Source == PowerSource.Battery) + power.ApplyQuietCpuMax(state, ResolveQuietCpuMax(config, powerSnapshot.BatteryPercent)); + wakeDevices.Apply(mode, config, state, () => store.Save(state)); + if (applyDisplay) displays.Apply(mode, state, config, applyDisplaySettings: true, powerSnapshot: powerSnapshot); state.ActiveMode = mode; - return new AgentResponse(true, $"Applied {mode} mode.", GetStatus(state)); + _ = log.TryWrite("mode.applied", mode.ToString()); + return new AgentResponse(true, $"Applied {mode} mode.", GetStatus(state, config, powerSnapshot)); } finally { store.Save(state); } } - private static PowerSource GetPowerSource() => System.Windows.Forms.SystemInformation.PowerStatus.PowerLineStatus switch + private static void RequireAdministrator() { - System.Windows.Forms.PowerLineStatus.Online => PowerSource.Ac, - System.Windows.Forms.PowerLineStatus.Offline => PowerSource.Battery, - _ => PowerSource.Unknown - }; + if (!IsAdministrator()) + throw new UnauthorizedAccessException("Applying or restoring Windows policies requires an elevated OpenSynapse.Agent."); + } - private static void RequireAdministrator() + private static bool IsAdministrator() { using var identity = WindowsIdentity.GetCurrent(); - if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) - throw new UnauthorizedAccessException("Applying or restoring Windows policies requires an elevated OpenSynapse.Agent."); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + + private (OpenSynapseState State, OpenSynapseConfig Config) LoadContext() + { + var state = store.Load(); + var config = configurationStore.Load(state.LegacySelection); + if (state.LegacySelection is not null) + { + state.LegacySelection = null; + store.Save(state); + _ = log.TryWrite("configuration.migrated", "Moved mode selection from state schema to config schema."); + } + return (state, config); + } + + private OperatingMode ResolveDesiredMode( + OpenSynapseState state, + OpenSynapseConfig config, + PowerSnapshot powerSnapshot, + TelemetrySnapshot telemetrySnapshot) + { + if (state.TemporaryMode is { } temporary) + { + var expired = temporary.ExpiresAt is not null && temporary.ExpiresAt <= DateTimeOffset.UtcNow; + var powerChanged = temporary.UntilPowerChange && temporary.StartedSupplyType != powerSnapshot.SupplyType; + if (expired || powerChanged) + { + state.TemporaryMode = null; + _ = log.TryWrite("temporary-mode.expired", expired ? "duration elapsed" : "supply changed"); + } + else + { + return temporary.Mode == OperatingMode.Balanced + && !ModeSelector.IsBalancedEligible(powerSnapshot, config.BalancedBatteryThresholdPercent) + ? OperatingMode.Quiet + : temporary.Mode; + } + } + + if (config.Selection != ModeSelection.Auto || !config.SmartAutomationEnabled) + return ModeSelector.Resolve(config.Selection, powerSnapshot, config.BalancedBatteryThresholdPercent); + + var decision = SmartAutomationEngine.Evaluate( + new SmartAutomationInput( + powerSnapshot.SupplyType, + powerSnapshot.BatteryPercent, + telemetrySnapshot.CpuPercent, + telemetrySnapshot.GpuPercent, + telemetrySnapshot.ForegroundProcess, + config.SmartFullscreenEnabled && telemetrySnapshot.ForegroundFullscreen, + telemetrySnapshot.SessionLocked, + telemetrySnapshot.RunningProcesses), + config.ToSmartAutomationSettings(), + state.SmartAutomation, + DateTimeOffset.UtcNow); + return decision.Mode; + } + + private SmartAutomationStatus GetSmartStatus(OpenSynapseState state) => new( + state.SmartAutomation.CurrentMode, + state.SmartAutomation.CandidateMode, + state.SmartAutomation.CandidateSamples, + state.SmartAutomation.LastReason, + state.SmartAutomation.MatchedRule, + state.SmartAutomation.DgpuActivitySuspected, + state.SmartAutomation.DgpuActivityConfidence, + state.SmartAutomation.DgpuLeakSamples, + state.SmartAutomation.DgpuConsumers); + + private void WriteRuntime(bool force = false) + { + var now = DateTimeOffset.UtcNow; + if (!force && now - lastRuntimeWrite < TimeSpan.FromSeconds(60)) return; + try + { + var path = Path.Combine(Path.GetDirectoryName(log.FilePath)!, "runtime.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".tmp"; + var record = new + { + Version = "OpenSynapse", + Health = health, + ConsecutiveFailures = consecutiveFailures, + LastHeartbeatUtc = now, + LastSuccessfulTickUtc = health == RuntimeHealth.Healthy ? now : (DateTimeOffset?)null + }; + File.WriteAllText(temporary, JsonSerializer.Serialize(record, AgentJson.Options)); + File.Move(temporary, path, true); + lastRuntimeWrite = now; + } + catch { } + } + + private static int ResolveQuietCpuMax(OpenSynapseConfig config, int? batteryPercent) + { + if (batteryPercent is null || batteryPercent >= config.QuietCpuMediumThreshold) + return config.QuietCpuMaxHighBattery; + if (batteryPercent >= config.QuietCpuLowThreshold) + return config.QuietCpuMaxMediumBattery; + return config.QuietCpuMaxLowBattery; + } + + private void UpdateDgpuDiagnostics( + OpenSynapseState state, + OpenSynapseConfig config, + PowerSnapshot powerSnapshot, + TelemetrySnapshot telemetrySnapshot) + { + var smart = state.SmartAutomation; + var portablePower = powerSnapshot.SupplyType is SupplyType.Battery or SupplyType.LowPowerPd; + var signal = portablePower + && telemetrySnapshot.GpuAvailable + && (telemetrySnapshot.DgpuPercent >= config.DgpuLeakUtilizationPercent + || telemetrySnapshot.DgpuDedicatedMb >= config.DgpuLeakMemoryMb); + var wasSuspected = smart.DgpuActivitySuspected; + smart.DgpuLeakSamples = signal + ? Math.Min(config.DgpuLeakMinimumSamples, smart.DgpuLeakSamples + 1) + : 0; + smart.DgpuActivitySuspected = smart.DgpuLeakSamples >= config.DgpuLeakMinimumSamples; + smart.DgpuConsumers = (telemetrySnapshot.DgpuConsumers ?? []) + .Where(consumer => consumer.Discrete + && (consumer.UtilizationPercent >= 0.5 || consumer.DedicatedBytes >= 64L * 1024L * 1024L)) + .Take(8) + .ToList(); + smart.DgpuActivityConfidence = !smart.DgpuActivitySuspected + ? "None" + : telemetrySnapshot.DgpuPercent >= 5 + && (telemetrySnapshot.BatteryDischargeAverage10mWatts + ?? telemetrySnapshot.BatteryDischargeEmaWatts + ?? 0) >= config.DgpuActivityDischargeThresholdW + ? "High" + : telemetrySnapshot.DgpuPercent >= config.DgpuLeakUtilizationPercent + || (telemetrySnapshot.BatteryDischargeAverage10mWatts + ?? telemetrySnapshot.BatteryDischargeEmaWatts + ?? 0) >= config.DgpuActivityDischargeThresholdW + ? "Medium" + : "Low"; + + if (!wasSuspected && smart.DgpuActivitySuspected) + { + _ = log.TryWrite( + "gpu.activity.suspected", + $"Sustained dGPU activity suspected after {smart.DgpuLeakSamples} sample(s); " + + $"confidence={smart.DgpuActivityConfidence}; " + + $"consumers={string.Join(',', smart.DgpuConsumers.Select(consumer => consumer.ProcessName))}."); + } + else if (wasSuspected && !smart.DgpuActivitySuspected) + { + _ = log.TryWrite("gpu.activity.cleared", "Suspected dGPU activity signal cleared."); + } + } + + private AgentResponse RunSelfTest() + { + var checks = new List(); + OpenSynapseState? state = null; + try + { + state = store.Load(); + checks.Add(new DiagnosticCheck( + "Captured state", + DiagnosticStatus.Passed, + $"Schema {state.SchemaVersion} loaded.")); + var hasRollback = state.ActiveMode is not null + || state.OriginalPowerPlan is not null + || state.OriginalBrightness is not null + || state.AdvancedColors.Count != 0 + || state.DisplayScales.Count != 0 + || state.DisabledWakeDevices.Count != 0; + checks.Add(new DiagnosticCheck( + "Rollback snapshot", + hasRollback ? DiagnosticStatus.Warning : DiagnosticStatus.Passed, + hasRollback ? "Captured rollback data is active or pending." : "No rollback is pending.")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Captured state", DiagnosticStatus.Failed, ex.Message)); + } + + try + { + var config = configurationStore.Load(state?.LegacySelection); + checks.Add(new DiagnosticCheck( + "Configuration", + DiagnosticStatus.Passed, + $"Schema {config.SchemaVersion} loaded; selection is {config.Selection}.")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Configuration", DiagnosticStatus.Failed, ex.Message)); + } + + var isAdministrator = IsAdministrator(); + checks.Add(new DiagnosticCheck( + "Administrator", + isAdministrator ? DiagnosticStatus.Passed : DiagnosticStatus.Warning, + isAdministrator ? "Agent is elevated." : "Read-only checks work, but policy changes require elevation.")); + + try + { + checks.Add(new DiagnosticCheck( + "Power plan", + DiagnosticStatus.Passed, + $"Active plan is {power.GetActiveGuid()}.")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Power plan", DiagnosticStatus.Failed, ex.Message)); + } + + try + { + var snapshot = powerSupply.GetSnapshot(); + var ambiguous = snapshot.SupplyType is SupplyType.Unknown or SupplyType.UnknownAc; + checks.Add(new DiagnosticCheck( + "Power supply", + ambiguous ? DiagnosticStatus.Warning : DiagnosticStatus.Passed, + $"{snapshot.SupplyType}; battery {snapshot.BatteryPercent?.ToString() ?? "unavailable"}%; " + + $"adapter limit {snapshot.AdapterLimitWatts?.ToString("0.#") ?? "unavailable"} W.")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Power supply", DiagnosticStatus.Failed, ex.Message)); + } + + try + { + var count = displays.GetActiveDisplayCount(); + checks.Add(new DiagnosticCheck( + "Displays", + count == 0 ? DiagnosticStatus.Warning : DiagnosticStatus.Passed, + $"Detected {count} active display(s).")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Displays", DiagnosticStatus.Failed, ex.Message)); + } + + try + { + var dynamic = PowerPilotNative.DynamicRefreshManager.GetStatus(); + checks.Add(new DiagnosticCheck( + "Native dynamic refresh", + dynamic.Supported ? DiagnosticStatus.Passed : DiagnosticStatus.Warning, + dynamic.Message)); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Native dynamic refresh", DiagnosticStatus.Warning, ex.Message)); + } + + try + { + var telemetrySnapshot = telemetry.Read(); + var gpuStatus = telemetrySnapshot.GpuAvailable + ? $"GPU {telemetrySnapshot.GpuPercent:0.#}%; dGPU {telemetrySnapshot.DgpuPercent:0.#}% / {telemetrySnapshot.DgpuDedicatedMb:0.#} MB." + : $"Windows GPU Performance Counter is unavailable; Smart Auto will use CPU/window signals. {telemetrySnapshot.GpuError}"; + var batteryStatus = telemetrySnapshot.Confidence == "Unavailable" + ? "Battery Class API and SystemBatteryState returned no sample." + : $"{telemetrySnapshot.Confidence}; discharge EMA {telemetrySnapshot.BatteryDischargeEmaWatts?.ToString("0.#") ?? "unavailable"} W."; + checks.Add(new DiagnosticCheck( + "Runtime telemetry", + telemetrySnapshot.GpuAvailable || telemetrySnapshot.Confidence != "Unavailable" + ? DiagnosticStatus.Passed + : DiagnosticStatus.Warning, + $"{gpuStatus} {batteryStatus}")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Runtime telemetry", DiagnosticStatus.Warning, ex.Message)); + } + + try + { + var devices = wakeDevices.GetWakeArmedDevices(); + checks.Add(new DiagnosticCheck( + "Wake devices", + DiagnosticStatus.Passed, + $"Detected {devices.Count} currently wake-armed device(s).")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Wake devices", DiagnosticStatus.Warning, ex.Message)); + } + + try + { + var count = deathAdder.ReadDevices().Count; + checks.Add(new DiagnosticCheck( + "Razer devices", + count == 0 ? DiagnosticStatus.Warning : DiagnosticStatus.Passed, + count == 0 ? "No supported Razer device detected." : $"Detected {count} supported device(s).")); + } + catch (Exception ex) + { + checks.Add(new DiagnosticCheck("Razer devices", DiagnosticStatus.Failed, ex.Message)); + } + + var logWritable = log.TryWrite("self-test", "Read-only diagnostics completed."); + checks.Add(new DiagnosticCheck( + "Local log", + logWritable ? DiagnosticStatus.Passed : DiagnosticStatus.Failed, + logWritable ? $"Writable at {log.FilePath}." : "Cannot write the local agent log.")); + + var failures = checks.Count(check => check.Status == DiagnosticStatus.Failed); + var warnings = checks.Count(check => check.Status == DiagnosticStatus.Warning); + return new AgentResponse( + failures == 0, + $"Self-test completed with {failures} failure(s) and {warnings} warning(s).", + Diagnostics: checks); + } + + private static void EnsureBalancedEligible( + OperatingMode mode, + PowerSnapshot powerSnapshot, + OpenSynapseConfig config) + { + if (mode != OperatingMode.Balanced + || ModeSelector.IsBalancedEligible(powerSnapshot, config.BalancedBatteryThresholdPercent)) return; + throw new InvalidOperationException( + $"Balanced mode requires at least {config.BalancedBatteryThresholdPercent}% battery; " + + $"current charge is {powerSnapshot.BatteryPercent?.ToString() ?? "unavailable"}%."); + } + + private IReadOnlyList GetWakeDevicesForStatus() + { + try { return wakeDevices.GetWakeArmedDevices(); } + catch (Exception ex) + { + _ = log.TryWrite("wake-device.query.failed", ex.Message); + return []; + } } } diff --git a/src/OpenSynapse.Agent/AgentLog.cs b/src/OpenSynapse.Agent/AgentLog.cs new file mode 100644 index 0000000..086ec9a --- /dev/null +++ b/src/OpenSynapse.Agent/AgentLog.cs @@ -0,0 +1,48 @@ +using System.Text; + +namespace OpenSynapse.Agent; + +internal sealed class AgentLog +{ + private readonly object gate = new(); + private readonly string path; + private readonly long maximumBytes; + + public AgentLog(string? path = null, long maximumBytes = 1024 * 1024) + { + if (maximumBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + this.path = path ?? System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "logs", + "agent.log"); + this.maximumBytes = maximumBytes; + } + + public string FilePath => path; + + public bool TryWrite(string category, string message) + { + lock (gate) + { + try + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + if (File.Exists(path) && new FileInfo(path).Length >= maximumBytes) + File.Move(path, path + ".1", overwrite: true); + var line = $"{DateTimeOffset.Now:O}\t{Sanitize(category)}\t{Sanitize(message)}{Environment.NewLine}"; + File.AppendAllText(path, line, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return true; + } + catch + { + return false; + } + } + } + + private static string Sanitize(string value) => value + .Replace('\r', ' ') + .Replace('\n', ' ') + .Trim(); +} diff --git a/src/OpenSynapse.Agent/AgentServer.cs b/src/OpenSynapse.Agent/AgentServer.cs index bda29e8..63b3e23 100644 --- a/src/OpenSynapse.Agent/AgentServer.cs +++ b/src/OpenSynapse.Agent/AgentServer.cs @@ -1,4 +1,6 @@ using System.IO.Pipes; +using System.Security.Principal; +using System.Text; using System.Text.Json; using Microsoft.Win32; using OpenSynapse.Core; @@ -7,30 +9,104 @@ namespace OpenSynapse.Agent; internal sealed class AgentServer(AgentController controller) { + internal const int MaxRequestCharacters = 32768; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + private readonly object displayEventGate = new(); + private CancellationTokenSource? displayDebounceCancellation; + private Task displayReapply = Task.CompletedTask; + public async Task RunAsync(CancellationToken cancellationToken) { SystemEvents.PowerModeChanged += PowerModeChanged; - controller.ApplyCurrentSelection(); + SystemEvents.DisplaySettingsChanged += DisplaySettingsChanged; + controller.ApplyCurrentSelection(force: true); + using var monitorCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var monitor = MonitorSelectionAsync(monitorCancellation.Token); try { while (!cancellationToken.IsCancellationRequested) { - await using var pipe = new NamedPipeServerStream( + await using var pipe = NamedPipeServerStreamAcl.Create( "OpenSynapse.Agent", PipeDirection.InOut, 1, PipeTransmissionMode.Byte, - PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + PipeOptions.Asynchronous, + 0, + 0, + CreatePipeSecurity()); await pipe.WaitForConnectionAsync(cancellationToken); if (await HandleConnectionAsync(pipe, cancellationToken)) break; } } - finally { SystemEvents.PowerModeChanged -= PowerModeChanged; } + finally + { + monitorCancellation.Cancel(); + try { await monitor; } catch (OperationCanceledException) { } + SystemEvents.DisplaySettingsChanged -= DisplaySettingsChanged; + await CancelDisplayReapplyAsync(); + SystemEvents.PowerModeChanged -= PowerModeChanged; + controller.Dispose(); + } } private void PowerModeChanged(object sender, PowerModeChangedEventArgs args) { - if (args.Mode == PowerModes.StatusChange) _ = Task.Run(controller.ApplyCurrentSelection); + if (args.Mode == PowerModes.StatusChange) _ = Task.Run(() => controller.ApplyCurrentSelection()); + } + + private async Task MonitorSelectionAsync(CancellationToken cancellationToken) + { + var interval = TimeSpan.FromSeconds(5); + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(interval, cancellationToken); + var succeeded = controller.ApplyCurrentSelection(); + interval = succeeded + ? TimeSpan.FromSeconds(5) + : TimeSpan.FromSeconds(Math.Min(interval.TotalSeconds * 2, 60)); + } + } + + private void DisplaySettingsChanged(object? sender, EventArgs args) + { + lock (displayEventGate) + { + displayDebounceCancellation?.Cancel(); + var cancellation = new CancellationTokenSource(); + displayDebounceCancellation = cancellation; + displayReapply = ReapplyDisplayAfterDelayAsync(cancellation); + } + } + + private async Task ReapplyDisplayAfterDelayAsync(CancellationTokenSource cancellation) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellation.Token); + controller.ReapplyDisplayPolicy(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + finally + { + lock (displayEventGate) + { + if (ReferenceEquals(displayDebounceCancellation, cancellation)) + displayDebounceCancellation = null; + } + cancellation.Dispose(); + } + } + + private async Task CancelDisplayReapplyAsync() + { + Task pending; + lock (displayEventGate) + { + displayDebounceCancellation?.Cancel(); + pending = displayReapply; + } + await pending; } private async Task HandleConnectionAsync(Stream stream, CancellationToken cancellationToken) @@ -41,11 +117,16 @@ private async Task HandleConnectionAsync(Stream stream, CancellationToken AgentRequest? request = null; try { - var line = await reader.ReadLineAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(line) || line.Length > 4096) - throw new InvalidDataException("Request must be one JSON line no longer than 4096 characters."); + using var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestCancellation.CancelAfter(RequestTimeout); + var line = await ReadBoundedLineAsync(reader, requestCancellation.Token); + if (string.IsNullOrWhiteSpace(line)) + throw new InvalidDataException( + $"Request must be one JSON line no longer than {MaxRequestCharacters} characters."); request = JsonSerializer.Deserialize(line, AgentJson.Options) ?? throw new InvalidDataException("Request is empty."); + if (!IsPipeOperationAllowed(request.Operation)) + throw new InvalidDataException("Uninstall cleanup is only available to the elevated maintenance CLI."); response = await controller.HandleAsync(request); } catch (Exception ex) @@ -55,4 +136,44 @@ private async Task HandleConnectionAsync(Stream stream, CancellationToken await writer.WriteLineAsync(JsonSerializer.Serialize(response, AgentJson.Options)); return response.Success && request?.Operation == AgentOperation.Shutdown; } + + internal static bool IsPipeOperationAllowed(AgentOperation operation) => + operation != AgentOperation.UninstallCleanup; + + private static System.IO.Pipes.PipeSecurity CreatePipeSecurity() + { + var userSid = WindowsIdentity.GetCurrent().User?.Value + ?? throw new InvalidOperationException("The current Windows user SID is unavailable."); + var security = new System.IO.Pipes.PipeSecurity(); + // The agent runs elevated, but the UI is intentionally unelevated. The + // pipe is restricted to this user while its integrity label permits the + // same user's medium-integrity desktop process to connect. + security.SetSecurityDescriptorSddlForm( + $"D:(A;;GA;;;{userSid})(A;;GA;;;SY)(A;;GA;;;BA)S:(ML;;NW;;;LW)"); + return security; + } + + internal static async Task ReadBoundedLineAsync( + TextReader reader, + CancellationToken cancellationToken) + { + var builder = new StringBuilder(1024); + var buffer = new char[1024]; + while (true) + { + var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken); + if (read == 0) + return builder.Length == 0 ? null : builder.ToString().TrimEnd('\r'); + + for (var index = 0; index < read; index++) + { + var character = buffer[index]; + if (character is '\r' or '\n') return builder.ToString(); + if (builder.Length >= MaxRequestCharacters) + throw new InvalidDataException( + $"Request exceeds {MaxRequestCharacters} characters."); + builder.Append(character); + } + } + } } diff --git a/src/OpenSynapse.Agent/DiagnosticExporter.cs b/src/OpenSynapse.Agent/DiagnosticExporter.cs new file mode 100644 index 0000000..69f9bb8 --- /dev/null +++ b/src/OpenSynapse.Agent/DiagnosticExporter.cs @@ -0,0 +1,60 @@ +using System.IO.Compression; +using System.Text.Json; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal static class DiagnosticExporter +{ + public static string Export( + OpenSynapseConfig config, + OpenSynapseState state, + PowerSupplyProbe powerSupply, + string logPath, + TelemetrySnapshot? telemetry = null, + string? telemetryHistoryPath = null) + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "diagnostics"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, $"OpenSynapse-diagnostics-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.zip"); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + AddText(archive, "config.json", JsonSerializer.Serialize(config, AgentJson.Options)); + AddText(archive, "state.json", JsonSerializer.Serialize(state, AgentJson.Options)); + AddText(archive, "power.json", JsonSerializer.Serialize(powerSupply.GetSnapshot(), AgentJson.Options)); + if (telemetry is not null) + AddText(archive, "telemetry.json", JsonSerializer.Serialize(telemetry, AgentJson.Options)); + AddText(archive, "environment.txt", $"OS={Environment.OSVersion}{Environment.NewLine}" + + $"Runtime={Environment.Version}{Environment.NewLine}" + + $"Machine={Environment.MachineName}{Environment.NewLine}" + + $"Utc={DateTimeOffset.UtcNow:o}{Environment.NewLine}"); + AddText(archive, "powercfg.txt", ReadPowerCfg()); + if (File.Exists(logPath)) archive.CreateEntryFromFile(logPath, "OpenSynapse.log"); + if (!string.IsNullOrWhiteSpace(telemetryHistoryPath) && File.Exists(telemetryHistoryPath)) + archive.CreateEntryFromFile(telemetryHistoryPath, "telemetry.jsonl"); + if (!string.IsNullOrWhiteSpace(telemetryHistoryPath) && File.Exists(telemetryHistoryPath + ".old")) + archive.CreateEntryFromFile(telemetryHistoryPath + ".old", "telemetry.jsonl.old"); + var runtimePath = Path.Combine(Path.GetDirectoryName(logPath)!, "runtime.json"); + if (File.Exists(runtimePath)) archive.CreateEntryFromFile(runtimePath, "runtime.json"); + return path; + } + + private static void AddText(ZipArchive archive, string name, string contents) + { + var entry = archive.CreateEntry(name, CompressionLevel.Fastest); + using var writer = new StreamWriter(entry.Open()); + writer.Write(contents); + } + + private static string ReadPowerCfg() + { + try + { + var executable = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + return ProcessRunner.Run(executable, "/getactivescheme", "/query"); + } + catch (Exception ex) { return $"powercfg unavailable: {ex.Message}"; } + } +} diff --git a/src/OpenSynapse.Agent/DisplayPolicy.cs b/src/OpenSynapse.Agent/DisplayPolicy.cs index 0b7dd61..f0d7359 100644 --- a/src/OpenSynapse.Agent/DisplayPolicy.cs +++ b/src/OpenSynapse.Agent/DisplayPolicy.cs @@ -1,100 +1,245 @@ using OpenSynapse.Core; -using PowerPilotNative; - namespace OpenSynapse.Agent; internal sealed class DisplayPolicy { - public void CaptureForQuiet(OpenSynapseState state) + private readonly IDisplaySystem displaySystem; + + public DisplayPolicy() + : this(new WindowsDisplaySystem()) + { + } + + internal DisplayPolicy(IDisplaySystem displaySystem) + { + this.displaySystem = displaySystem; + } + + public int GetActiveDisplayCount() => displaySystem.GetDisplays().Count; + + public void Capture(OperatingMode mode, OpenSynapseState state, OpenSynapseConfig config) { - if (state.AdvancedColors.Count == 0) + if (config.ManageDisplayScaling) CaptureDisplayScales(state); + if (mode != OperatingMode.Performance) CaptureForLowPower(state, config); + } + + private void CaptureForLowPower(OpenSynapseState state, OpenSynapseConfig config) + { + if (config.ManageAdvancedColor) { try { - state.AdvancedColors = AdvancedColorManager.GetStatus() - .Where(item => item.Supported) - .Select(item => new AdvancedColorState(item.Key, item.Enabled)) - .ToList(); + var captured = state.AdvancedColors + .Select(item => item.Key) + .ToHashSet(StringComparer.Ordinal); + state.AdvancedColors.AddRange(displaySystem.GetAdvancedColors() + .Where(item => item.Supported && captured.Add(item.Key)) + .Select(item => new AdvancedColorState(item.Key, item.Enabled))); } catch { } } - state.OriginalBrightness ??= GetBrightness(); + if (config.ManageBrightness && state.OriginalBrightness is null) + { + try { state.OriginalBrightness = displaySystem.GetBrightness(); } catch { } + } } - public void Apply(OperatingMode mode, OpenSynapseState state) + public void Apply( + OperatingMode mode, + OpenSynapseState state, + OpenSynapseConfig config, + bool applyDisplaySettings = true, + PowerSnapshot? powerSnapshot = null) { - if (mode == OperatingMode.Quiet) + if (!applyDisplaySettings) return; + if (mode != OperatingMode.Performance) { - CaptureForQuiet(state); - foreach (var color in state.AdvancedColors) - try { AdvancedColorManager.SetEnabled(color.Key, false); } catch { } - _ = SetBrightness(40); - try { DisplayModeManager.ApplyQuietRefresh(60); } catch { } + CaptureForLowPower(state, config); + if (config.ManageAdvancedColor) + { + foreach (var color in state.AdvancedColors) + try { displaySystem.SetAdvancedColor(color.Key, false); } catch { } + } + else + { + RestoreAdvancedColors(state, clearCompleted: false); + } + if (config.ManageBrightness) + { + try + { + displaySystem.SetBrightness(ResolveBrightness(mode, config, powerSnapshot)); + } + catch { } + } + else + { + RestoreBrightness(state, clearCompleted: false); + } } else { - RestoreCapturedDisplayState(state); - try { DisplayModeManager.ApplyMaximumRefresh(); } catch { } + RestoreCapturedDisplayState(state, clearCompleted: false, restoreScales: false); } + ApplyRefreshPolicy(mode, config); + if (config.ManageDisplayScaling) + { + try + { + foreach (var display in displaySystem.GetDisplays()) + try + { + displaySystem.SetDisplayScale( + display.Key, + display.IsInternal + ? config.InternalDisplayScalePercent + : config.ExternalDisplayScalePercent); + } + catch { } + } + catch { } + } + else + { + RestoreDisplayScales(state, clearCompleted: false); + } + } + + public bool Restore(OpenSynapseState state) + { + RestoreCapturedDisplayState(state, clearCompleted: true, restoreScales: true); + var refreshRestored = true; + try { displaySystem.RestoreRefresh(); } + catch { refreshRestored = false; } + return state.AdvancedColors.Count == 0 + && state.DisplayScales.Count == 0 + && state.OriginalBrightness is null + && refreshRestored; + } + + private void CaptureDisplayScales(OpenSynapseState state) + { try { - foreach (var display in DisplayScaling.GetActiveDisplays()) - try { DisplayScaling.SetScale(display, display.IsInternal ? 150 : 125); } catch { } + var captured = state.DisplayScales + .Select(item => item.Key) + .ToHashSet(StringComparer.Ordinal); + state.DisplayScales.AddRange(displaySystem.GetDisplays() + .Where(display => captured.Add(display.Key)) + .Select(display => new DisplayScaleState(display.Key, display.CurrentScalePercent))); } catch { } } - public void Restore(OpenSynapseState state) + private void RestoreCapturedDisplayState(OpenSynapseState state, bool clearCompleted, bool restoreScales) { - RestoreCapturedDisplayState(state); - try { DisplayModeManager.RestoreRegistryModes(); } catch { } + RestoreAdvancedColors(state, clearCompleted); + RestoreBrightness(state, clearCompleted); + if (restoreScales) RestoreDisplayScales(state, clearCompleted); } - private static void RestoreCapturedDisplayState(OpenSynapseState state) + private void RestoreAdvancedColors(OpenSynapseState state, bool clearCompleted) { foreach (var color in state.AdvancedColors) - try { AdvancedColorManager.SetEnabled(color.Key, color.Enabled); } catch { } - try + try { displaySystem.SetAdvancedColor(color.Key, color.Enabled); } catch { } + if (clearCompleted) { - var current = AdvancedColorManager.GetStatus().ToDictionary(item => item.Key, item => item.Enabled); - state.AdvancedColors.RemoveAll(color => current.TryGetValue(color.Key, out var enabled) && enabled == color.Enabled); + try + { + var current = displaySystem.GetAdvancedColors().ToDictionary(item => item.Key, item => item.Enabled); + state.AdvancedColors.RemoveAll(color => current.TryGetValue(color.Key, out var enabled) && enabled == color.Enabled); + } + catch { } } - catch { } + } + + private void RestoreBrightness(OpenSynapseState state, bool clearCompleted) + { if (state.OriginalBrightness is int brightness) { - if (SetBrightness(brightness)) state.OriginalBrightness = null; + try { displaySystem.SetBrightness(brightness); } catch { } + if (clearCompleted) + { + try + { + if (displaySystem.GetBrightness() == brightness) state.OriginalBrightness = null; + } + catch { } + } } } - private static int? GetBrightness() + private void ApplyRefreshPolicy(OperatingMode mode, OpenSynapseConfig config) { - try + switch (config.RefreshPolicy) { - var output = RunPowerShell( - "(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightness | Where-Object Active | Select-Object -First 1).CurrentBrightness"); - return int.TryParse(output, out var brightness) ? brightness : null; + case RefreshPolicy.Unmanaged: + displaySystem.RestoreRefresh(); + break; + case RefreshPolicy.Maximum: + displaySystem.ApplyMaximumRefresh(); + break; + case RefreshPolicy.Fixed60: + displaySystem.ApplyFixedRefresh(60); + break; + case RefreshPolicy.Fixed120: + displaySystem.ApplyFixedRefresh(120); + break; + case RefreshPolicy.Fixed240: + displaySystem.ApplyFixedRefresh(240); + break; + case RefreshPolicy.DynamicNative: + displaySystem.ApplyDynamicNativeRefresh(); + break; + case RefreshPolicy.FollowMode when mode == OperatingMode.Performance: + displaySystem.ApplyMaximumRefresh(); + break; + case RefreshPolicy.FollowMode: + displaySystem.ApplyFixedRefresh( + mode == OperatingMode.Balanced + ? config.BalancedRefreshRateHz + : config.QuietRefreshRateHz); + break; } - catch { return null; } } - private static bool SetBrightness(int percent) + private void RestoreDisplayScales(OpenSynapseState state, bool clearCompleted) { + if (state.DisplayScales.Count == 0) return; try { - RunPowerShell( - "$methods = @(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods | Where-Object Active); if (-not $methods) { throw 'No active brightness controller.' }; $methods | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName WmiSetBrightness -Arguments @{Timeout=1;Brightness=[byte]" - + percent - + "} | Out-Null }"); - return true; + var displays = displaySystem.GetDisplays() + .ToDictionary(display => display.Key, StringComparer.Ordinal); + foreach (var captured in state.DisplayScales) + { + if (displays.ContainsKey(captured.Key)) + { + try { displaySystem.SetDisplayScale(captured.Key, captured.ScalePercent); } catch { } + } + } + + if (!clearCompleted) return; + var current = displaySystem.GetDisplays() + .ToDictionary(display => display.Key, StringComparer.Ordinal); + state.DisplayScales.RemoveAll(captured => + current.TryGetValue(captured.Key, out var display) + && display.CurrentScalePercent == captured.ScalePercent); } - catch { return false; } + catch { } } - private static string RunPowerShell(string command) => ProcessRunner.Run( - Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), - "-NoProfile", - "-NonInteractive", - "-Command", - command); + private static int ResolveBrightness( + OperatingMode mode, + OpenSynapseConfig config, + PowerSnapshot? powerSnapshot) + { + if (mode == OperatingMode.Balanced) return config.BalancedBrightnessPercent; + var target = config.QuietBrightnessPercent; + if (!config.AdaptiveQuietBrightness || powerSnapshot?.Source != PowerSource.Battery) + return target; + var battery = powerSnapshot.BatteryPercent; + var upperBound = battery is < 20 ? 20 : battery is < 50 ? 30 : 35; + return Math.Min(target, upperBound); + } } diff --git a/src/OpenSynapse.Agent/GpuTelemetryProbe.cs b/src/OpenSynapse.Agent/GpuTelemetryProbe.cs new file mode 100644 index 0000000..7f5056e --- /dev/null +++ b/src/OpenSynapse.Agent/GpuTelemetryProbe.cs @@ -0,0 +1,340 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +/// +/// Read-only Windows GPU telemetry. The Performance Counter API is sampled on a +/// background thread so Smart Auto and the UI only consume a cached snapshot. +/// +internal sealed class GpuTelemetryProbe : IDisposable +{ + private readonly object gate = new(); + private readonly Dictionary engineCounters = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary memoryBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly CancellationTokenSource stop = new(); + private readonly Task worker; + private Dictionary adapters = new(StringComparer.OrdinalIgnoreCase); + private DateTimeOffset lastCounterRefresh = DateTimeOffset.MinValue; + private DateTimeOffset lastMemoryRefresh = DateTimeOffset.MinValue; + private TelemetrySample latest = TelemetrySample.Unavailable("GPU telemetry is starting."); + + private static readonly Regex EngineInstancePattern = new( + @"pid_(?\d+)_luid_0x(?[0-9a-f]+)_0x(?[0-9a-f]+)_phys_\d+_eng_\d+_engtype_(?.+)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex MemoryInstancePattern = new( + @"pid_(?\d+)_luid_0x(?[0-9a-f]+)_0x(?[0-9a-f]+)_phys_\d+$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public GpuTelemetryProbe() + { + latest = SampleCore(); + worker = Task.Run(SampleLoopAsync); + } + + public TelemetrySample ReadLatest() + { + lock (gate) return latest; + } + + public void Dispose() + { + stop.Cancel(); + try { worker.Wait(TimeSpan.FromSeconds(2)); } + catch (AggregateException) { } + lock (gate) + { + foreach (var counter in engineCounters.Values) counter.Dispose(); + engineCounters.Clear(); + } + stop.Dispose(); + } + + private async Task SampleLoopAsync() + { + while (!stop.IsCancellationRequested) + { + TelemetrySample sample; + try { sample = SampleCore(); } + catch (Exception ex) { sample = TelemetrySample.Unavailable(ex.Message); } + lock (gate) latest = sample; + try { await Task.Delay(TimeSpan.FromSeconds(5), stop.Token); } + catch (OperationCanceledException) { } + } + } + + private TelemetrySample SampleCore() + { + lock (gate) + { + try + { + if (DateTimeOffset.UtcNow - lastCounterRefresh >= TimeSpan.FromSeconds(15)) + { + adapters = ReadAdapters(); + RefreshEngineCounters(); + lastCounterRefresh = DateTimeOffset.UtcNow; + } + + var consumers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var total = 0d; + var discrete = 0d; + foreach (var entry in engineCounters) + { + var match = EngineInstancePattern.Match(entry.Key); + if (!match.Success || !IsSupportedEngine(match.Groups["type"].Value)) continue; + double value; + try { value = Math.Max(0, entry.Value.NextValue()); } + catch { continue; } + if (!TryParseInstance(match, out var pid, out var luid)) continue; + + if (!adapters.TryGetValue(luid, out var adapter)) + adapter = new AdapterInfo("Unknown adapter", false); + var key = $"{pid}:{luid}"; + if (!consumers.TryGetValue(key, out var consumer)) + { + consumer = new ConsumerBuilder( + pid, + ReadProcessName(pid), + adapter.Name, + adapter.Discrete); + consumers[key] = consumer; + } + consumer.UtilizationPercent += value; + total += value; + if (adapter.Discrete) discrete += value; + } + + RefreshMemoryCounters(); + var discreteDedicatedBytes = 0L; + foreach (var entry in memoryBytes) + { + var match = MemoryInstancePattern.Match(entry.Key); + if (!match.Success || !TryParseInstance(match, out var pid, out var luid)) continue; + if (!adapters.TryGetValue(luid, out var adapter) || !adapter.Discrete) continue; + var key = $"{pid}:{luid}"; + if (!consumers.TryGetValue(key, out var consumer)) + { + consumer = new ConsumerBuilder(pid, ReadProcessName(pid), adapter.Name, true); + consumers[key] = consumer; + } + consumer.DedicatedBytes = Math.Max(0, entry.Value); + discreteDedicatedBytes += consumer.DedicatedBytes; + } + + var snapshots = consumers.Values + .OrderByDescending(item => item.UtilizationPercent) + .ThenByDescending(item => item.DedicatedBytes) + .Take(16) + .Select(item => new GpuConsumerSnapshot( + item.ProcessId, + item.ProcessName, + item.AdapterName, + Math.Round(Math.Max(0, item.UtilizationPercent), 1), + item.DedicatedBytes, + item.Discrete)) + .ToArray(); + return new TelemetrySample( + adapters.Count > 0, + Math.Round(Math.Min(100, total), 1), + Math.Round(Math.Min(100, discrete), 1), + discreteDedicatedBytes, + snapshots, + string.Empty); + } + catch (Exception ex) + { + return TelemetrySample.Unavailable(ex.Message); + } + } + } + + private void RefreshEngineCounters() + { + var category = new PerformanceCounterCategory("GPU Engine"); + var current = category.GetInstanceNames() + .Where(name => EngineInstancePattern.IsMatch(name) + && IsSupportedEngine(EngineInstancePattern.Match(name).Groups["type"].Value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var removed in engineCounters.Keys.Where(name => !current.Contains(name)).ToArray()) + { + engineCounters[removed].Dispose(); + engineCounters.Remove(removed); + } + + foreach (var name in current) + { + if (engineCounters.ContainsKey(name)) continue; + var counter = new PerformanceCounter("GPU Engine", "Utilization Percentage", name, true); + try + { + _ = counter.NextValue(); + engineCounters[name] = counter; + } + catch { counter.Dispose(); } + } + } + + private void RefreshMemoryCounters() + { + if (DateTimeOffset.UtcNow - lastMemoryRefresh < TimeSpan.FromSeconds(30)) return; + try + { + memoryBytes.Clear(); + var category = new PerformanceCounterCategory("GPU Process Memory"); + foreach (var instance in category.GetInstanceNames()) + { + var match = MemoryInstancePattern.Match(instance); + if (!match.Success || !TryParseInstance(match, out _, out var luid)) continue; + if (!adapters.TryGetValue(luid, out var adapter) || !adapter.Discrete) continue; + using var counter = new PerformanceCounter("GPU Process Memory", "Dedicated Usage", instance, true); + try { memoryBytes[instance] = Math.Max(0, counter.RawValue); } + catch { } + } + } + catch { } + finally { lastMemoryRefresh = DateTimeOffset.UtcNow; } + } + + private static bool IsSupportedEngine(string type) => + type.Equals("3D", StringComparison.OrdinalIgnoreCase) + || type.Equals("Compute", StringComparison.OrdinalIgnoreCase) + || type.Equals("Cuda", StringComparison.OrdinalIgnoreCase) + || type.Equals("VideoEncode", StringComparison.OrdinalIgnoreCase) + || type.Equals("VideoDecode", StringComparison.OrdinalIgnoreCase) + || type.Equals("Copy", StringComparison.OrdinalIgnoreCase); + + private static string ReadProcessName(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return process.ProcessName; + } + catch { return $"pid {processId}"; } + } + + private static bool TryParseInstance(Match match, out int processId, out string luid) + { + processId = 0; + luid = string.Empty; + if (!int.TryParse(match.Groups["pid"].Value, out processId) + || !uint.TryParse(match.Groups["high"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var high) + || !uint.TryParse(match.Groups["low"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var low)) + return false; + luid = $"{high:x8}:{low:x8}"; + return true; + } + + private static Dictionary ReadAdapters() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var interfaceId = typeof(IDXGIFactory1).GUID; + if (CreateDXGIFactory1(ref interfaceId, out var factory) < 0 || factory is null) return result; + try + { + for (uint index = 0; ; index++) + { + if (factory.EnumAdapters1(index, out var adapter) != 0 || adapter is null) break; + try + { + if (adapter.GetDesc1(out var description) == 0) + { + var software = (description.Flags & 2) != 0; + var discrete = !software && description.DedicatedVideoMemory.ToUInt64() >= 1024UL * 1024UL * 1024UL; + result[$"{unchecked((uint)description.AdapterLuid.HighPart):x8}:{description.AdapterLuid.LowPart:x8}"] = + new AdapterInfo(description.Description?.TrimEnd('\0') ?? string.Empty, discrete); + } + } + finally { Marshal.FinalReleaseComObject(adapter); } + } + } + finally { Marshal.FinalReleaseComObject(factory); } + return result; + } + + private sealed record AdapterInfo(string Name, bool Discrete); + + private sealed class ConsumerBuilder(int processId, string processName, string adapterName, bool discrete) + { + public int ProcessId { get; } = processId; + public string ProcessName { get; } = processName; + public string AdapterName { get; } = adapterName; + public bool Discrete { get; } = discrete; + public double UtilizationPercent { get; set; } + public long DedicatedBytes { get; set; } + } + + internal sealed record TelemetrySample( + bool Available, + double TotalUtilizationPercent, + double DiscreteUtilizationPercent, + long DiscreteDedicatedBytes, + IReadOnlyList Consumers, + string Error) + { + public static TelemetrySample Unavailable(string error) => + new(false, -1, -1, -1, [], error); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Luid + { + public uint LowPart; + public int HighPart; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DxgiAdapterDescription + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string Description; + public uint VendorId; + public uint DeviceId; + public uint SubSysId; + public uint Revision; + public UIntPtr DedicatedVideoMemory; + public UIntPtr DedicatedSystemMemory; + public UIntPtr SharedSystemMemory; + public Luid AdapterLuid; + public uint Flags; + } + + [ComImport, Guid("29038F61-3839-4626-91FD-086879011A05"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IDXGIAdapter1 + { + [PreserveSig] int SetPrivateData(ref Guid name, uint dataSize, IntPtr data); + [PreserveSig] int SetPrivateDataInterface(ref Guid name, IntPtr unknown); + [PreserveSig] int GetPrivateData(ref Guid name, ref uint dataSize, IntPtr data); + [PreserveSig] int GetParent(ref Guid interfaceId, out IntPtr parent); + [PreserveSig] int EnumOutputs(uint output, out IntPtr outputInterface); + [PreserveSig] int GetDesc(IntPtr description); + [PreserveSig] int CheckInterfaceSupport(ref Guid interfaceName, out long userModeDriverVersion); + [PreserveSig] int GetDesc1(out DxgiAdapterDescription description); + } + + [ComImport, Guid("770AAE78-F26F-4DBA-A829-253C83D1B387"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IDXGIFactory1 + { + [PreserveSig] int SetPrivateData(ref Guid name, uint dataSize, IntPtr data); + [PreserveSig] int SetPrivateDataInterface(ref Guid name, IntPtr unknown); + [PreserveSig] int GetPrivateData(ref Guid name, ref uint dataSize, IntPtr data); + [PreserveSig] int GetParent(ref Guid interfaceId, out IntPtr parent); + [PreserveSig] int EnumAdapters(uint adapter, out IntPtr adapterInterface); + [PreserveSig] int MakeWindowAssociation(IntPtr windowHandle, uint flags); + [PreserveSig] int GetWindowAssociation(out IntPtr windowHandle); + [PreserveSig] int CreateSwapChain(IntPtr device, IntPtr description, out IntPtr swapChain); + [PreserveSig] int CreateSoftwareAdapter(IntPtr module, out IntPtr adapter); + [PreserveSig] int EnumAdapters1(uint adapter, out IDXGIAdapter1 adapterInterface); + [PreserveSig] bool IsCurrent(); + } + + [DllImport("dxgi.dll", PreserveSig = true)] + private static extern int CreateDXGIFactory1( + ref Guid interfaceId, + [MarshalAs(UnmanagedType.Interface)] out IDXGIFactory1 factory); +} diff --git a/src/OpenSynapse.Agent/OpenSynapse.Agent.csproj b/src/OpenSynapse.Agent/OpenSynapse.Agent.csproj index 26d86eb..d25ba2d 100644 --- a/src/OpenSynapse.Agent/OpenSynapse.Agent.csproj +++ b/src/OpenSynapse.Agent/OpenSynapse.Agent.csproj @@ -2,6 +2,7 @@ + @@ -10,7 +11,7 @@ true true - $(NoWarn);CS8618;CS8622;CS8625 + $(NoWarn);CS8618;CS8622;CS8625;NU1510 enable enable diff --git a/src/OpenSynapse.Agent/OpenSynapseConfig.cs b/src/OpenSynapse.Agent/OpenSynapseConfig.cs new file mode 100644 index 0000000..c3174bf --- /dev/null +++ b/src/OpenSynapse.Agent/OpenSynapseConfig.cs @@ -0,0 +1,416 @@ +using System.Text.Json; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal sealed class OpenSynapseConfig +{ + public const int CurrentSchemaVersion = 10; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + public ModeSelection Selection { get; set; } = ModeSelection.Auto; + public int BalancedBatteryThresholdPercent { get; set; } = 50; + public bool ManageAdvancedColor { get; set; } = true; + public bool ManageBrightness { get; set; } = true; + public bool ManageDisplayScaling { get; set; } = true; + public RefreshPolicy RefreshPolicy { get; set; } = RefreshPolicy.FollowMode; + public int InternalDisplayScalePercent { get; set; } = 150; + public int ExternalDisplayScalePercent { get; set; } = 125; + public int BalancedBrightnessPercent { get; set; } = 60; + public int QuietBrightnessPercent { get; set; } = 40; + public int BalancedRefreshRateHz { get; set; } = 120; + public int QuietRefreshRateHz { get; set; } = 60; + public bool ManageWakeDevices { get; set; } + public List QuietWakeDeviceNames { get; set; } = []; + public bool SmartAutomationEnabled { get; set; } = true; + public int SmartHighPowerCpuEnter { get; set; } = 45; + public int SmartHighPowerCpuExit { get; set; } = 25; + public int SmartPortableCpuEnter { get; set; } = 35; + public int SmartPortableCpuExit { get; set; } = 18; + public int SmartLoadEnterSamples { get; set; } = 3; + public int SmartAppEnterSamples { get; set; } = 2; + public int SmartExitSamples { get; set; } = 12; + public int SmartMinimumDwellSeconds { get; set; } = 30; + public int SmartAppCpuFloor { get; set; } = 8; + public int SmartGpuEnter { get; set; } = 20; + public int SmartGpuExit { get; set; } = 5; + public bool SmartFullscreenEnabled { get; set; } = true; + public int SmartFullscreenCpuFloor { get; set; } = 15; + public int SmartFullscreenGpuFloor { get; set; } = 15; + public List SmartIgnoredFullscreenProcesses { get; set; } = + ["LockApp", "LogonUI", "explorer", "ShellExperienceHost", "StartMenuExperienceHost", "SearchHost", "SearchApp", "TextInputHost", "SystemSettings", "dwm", "Idle"]; + public List SmartHyperProcessNames { get; set; } = + ["blender", "Resolve", "Adobe Premiere Pro", "AfterFX", "UnrealEditor", "UE4Editor", "Unity", "3dsmax", "maya", "Cinebench", "occt", "FurMark", "FurMark_GUI"]; + public List SmartBalanceProcessNames { get; set; } = + ["Codex", "Code", "devenv", "WINWORD", "EXCEL", "POWERPNT", "Acrobat", "AcroRd32"]; + public List ApplicationRules { get; set; } = []; + public int DgpuLeakMemoryMb { get; set; } = 128; + public double DgpuLeakUtilizationPercent { get; set; } = 1; + public int DgpuLeakMinimumSamples { get; set; } = 6; + public double DgpuActivityDischargeThresholdW { get; set; } = 8; + public HyperCpuPolicy HyperCpuPolicy { get; set; } = HyperCpuPolicy.Sustained; + public bool AdaptiveQuietCpu { get; set; } = true; + public int QuietCpuMaxHighBattery { get; set; } = 75; + public int QuietCpuMaxMediumBattery { get; set; } = 65; + public int QuietCpuMaxLowBattery { get; set; } = 60; + public int QuietCpuMediumThreshold { get; set; } = 50; + public int QuietCpuLowThreshold { get; set; } = 20; + public bool AdaptiveQuietBrightness { get; set; } = true; + public bool SeamlessModeSwitching { get; set; } = true; + public int ProcessMaintenanceSeconds { get; set; } = 180; + + public void Validate() + { + if (!Enum.IsDefined(Selection)) + throw new InvalidDataException($"Unsupported mode selection {Selection}."); + ToDisplayPolicySettings().Validate(); + ToQuietMaintenanceSettings().Validate(); + ToSmartAutomationSettings().Validate(); + if (!Enum.IsDefined(HyperCpuPolicy)) + throw new InvalidDataException($"Unsupported Hyper CPU policy {HyperCpuPolicy}."); + ValidateRange(QuietCpuMaxHighBattery, 1, 100, nameof(QuietCpuMaxHighBattery)); + ValidateRange(QuietCpuMaxMediumBattery, 1, 100, nameof(QuietCpuMaxMediumBattery)); + ValidateRange(QuietCpuMaxLowBattery, 1, 100, nameof(QuietCpuMaxLowBattery)); + ValidateRange(QuietCpuMediumThreshold, 1, 99, nameof(QuietCpuMediumThreshold)); + ValidateRange(QuietCpuLowThreshold, 0, QuietCpuMediumThreshold - 1, nameof(QuietCpuLowThreshold)); + ValidateRange(DgpuLeakMemoryMb, 1, 16384, nameof(DgpuLeakMemoryMb)); + if (!double.IsFinite(DgpuLeakUtilizationPercent) || DgpuLeakUtilizationPercent is < 0 or > 100) + throw new InvalidDataException($"{nameof(DgpuLeakUtilizationPercent)} must be between 0 and 100."); + ValidateRange(DgpuLeakMinimumSamples, 1, 120, nameof(DgpuLeakMinimumSamples)); + if (!double.IsFinite(DgpuActivityDischargeThresholdW) || DgpuActivityDischargeThresholdW is < 0 or > 1000) + throw new InvalidDataException($"{nameof(DgpuActivityDischargeThresholdW)} must be between 0 and 1000."); + if (ProcessMaintenanceSeconds is < 30 or > 3600) + throw new InvalidDataException("Process maintenance interval must be between 30 and 3600 seconds."); + } + + public DisplayPolicySettings ToDisplayPolicySettings() => new( + BalancedBatteryThresholdPercent, + ManageAdvancedColor, + ManageBrightness, + ManageDisplayScaling, + RefreshPolicy, + InternalDisplayScalePercent, + ExternalDisplayScalePercent, + BalancedBrightnessPercent, + QuietBrightnessPercent, + BalancedRefreshRateHz, + QuietRefreshRateHz); + + public OpenSynapseConfig WithDisplayPolicy(DisplayPolicySettings settings) + { + settings.Validate(); + var updated = Copy(); + updated.BalancedBatteryThresholdPercent = settings.BalancedBatteryThresholdPercent; + updated.ManageAdvancedColor = settings.ManageAdvancedColor; + updated.ManageBrightness = settings.ManageBrightness; + updated.ManageDisplayScaling = settings.ManageDisplayScaling; + updated.RefreshPolicy = settings.RefreshPolicy; + updated.InternalDisplayScalePercent = settings.InternalDisplayScalePercent; + updated.ExternalDisplayScalePercent = settings.ExternalDisplayScalePercent; + updated.BalancedBrightnessPercent = settings.BalancedBrightnessPercent; + updated.QuietBrightnessPercent = settings.QuietBrightnessPercent; + updated.BalancedRefreshRateHz = settings.BalancedRefreshRateHz; + updated.QuietRefreshRateHz = settings.QuietRefreshRateHz; + return updated; + } + + public QuietMaintenanceSettings ToQuietMaintenanceSettings() => new( + ManageWakeDevices, + QuietWakeDeviceNames.AsReadOnly()); + + public OpenSynapseConfig WithQuietMaintenance(QuietMaintenanceSettings settings) + { + settings.Validate(); + var updated = Copy(); + updated.ManageWakeDevices = settings.ManageWakeDevices; + updated.QuietWakeDeviceNames = [.. settings.WakeDeviceNames]; + return updated; + } + + public OpenSynapseConfig WithApplicationRules(IReadOnlyList rules) + { + var updated = Copy(); + updated.ApplicationRules = [.. rules]; + updated.ToSmartAutomationSettings().Validate(); + return updated; + } + + public SmartAutomationSettings ToSmartAutomationSettings() => new( + SmartAutomationEnabled, + SmartHighPowerCpuEnter, + SmartHighPowerCpuExit, + SmartPortableCpuEnter, + SmartPortableCpuExit, + SmartLoadEnterSamples, + SmartAppEnterSamples, + SmartExitSamples, + SmartMinimumDwellSeconds, + SmartAppCpuFloor, + SmartGpuEnter, + SmartGpuExit, + SmartFullscreenCpuFloor, + SmartFullscreenGpuFloor, + BalancedBatteryThresholdPercent, + SmartHyperProcessNames.AsReadOnly(), + SmartBalanceProcessNames.AsReadOnly(), + SmartIgnoredFullscreenProcesses.AsReadOnly(), + ApplicationRules.AsReadOnly()); + + private static void ValidateRange(int value, int minimum, int maximum, string name) + { + if (value < minimum || value > maximum) + throw new InvalidDataException($"{name} must be between {minimum} and {maximum}."); + } + + private OpenSynapseConfig Copy() => new() + { + SchemaVersion = SchemaVersion, + Selection = Selection, + BalancedBatteryThresholdPercent = BalancedBatteryThresholdPercent, + ManageAdvancedColor = ManageAdvancedColor, + ManageBrightness = ManageBrightness, + ManageDisplayScaling = ManageDisplayScaling, + RefreshPolicy = RefreshPolicy, + InternalDisplayScalePercent = InternalDisplayScalePercent, + ExternalDisplayScalePercent = ExternalDisplayScalePercent, + BalancedBrightnessPercent = BalancedBrightnessPercent, + QuietBrightnessPercent = QuietBrightnessPercent, + BalancedRefreshRateHz = BalancedRefreshRateHz, + QuietRefreshRateHz = QuietRefreshRateHz, + ManageWakeDevices = ManageWakeDevices, + QuietWakeDeviceNames = [.. QuietWakeDeviceNames], + SmartAutomationEnabled = SmartAutomationEnabled, + SmartHighPowerCpuEnter = SmartHighPowerCpuEnter, + SmartHighPowerCpuExit = SmartHighPowerCpuExit, + SmartPortableCpuEnter = SmartPortableCpuEnter, + SmartPortableCpuExit = SmartPortableCpuExit, + SmartLoadEnterSamples = SmartLoadEnterSamples, + SmartAppEnterSamples = SmartAppEnterSamples, + SmartExitSamples = SmartExitSamples, + SmartMinimumDwellSeconds = SmartMinimumDwellSeconds, + SmartAppCpuFloor = SmartAppCpuFloor, + SmartGpuEnter = SmartGpuEnter, + SmartGpuExit = SmartGpuExit, + SmartFullscreenEnabled = SmartFullscreenEnabled, + SmartFullscreenCpuFloor = SmartFullscreenCpuFloor, + SmartFullscreenGpuFloor = SmartFullscreenGpuFloor, + SmartIgnoredFullscreenProcesses = [.. SmartIgnoredFullscreenProcesses], + SmartHyperProcessNames = [.. SmartHyperProcessNames], + SmartBalanceProcessNames = [.. SmartBalanceProcessNames], + ApplicationRules = [.. ApplicationRules], + DgpuLeakMemoryMb = DgpuLeakMemoryMb, + DgpuLeakUtilizationPercent = DgpuLeakUtilizationPercent, + DgpuLeakMinimumSamples = DgpuLeakMinimumSamples, + DgpuActivityDischargeThresholdW = DgpuActivityDischargeThresholdW, + HyperCpuPolicy = HyperCpuPolicy, + AdaptiveQuietCpu = AdaptiveQuietCpu, + QuietCpuMaxHighBattery = QuietCpuMaxHighBattery, + QuietCpuMaxMediumBattery = QuietCpuMaxMediumBattery, + QuietCpuMaxLowBattery = QuietCpuMaxLowBattery, + QuietCpuMediumThreshold = QuietCpuMediumThreshold, + QuietCpuLowThreshold = QuietCpuLowThreshold, + AdaptiveQuietBrightness = AdaptiveQuietBrightness, + SeamlessModeSwitching = SeamlessModeSwitching, + ProcessMaintenanceSeconds = ProcessMaintenanceSeconds + }; +} + +internal sealed class ConfigurationStore +{ + private readonly string path; + + public ConfigurationStore(string? path = null) + { + this.path = path ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "config.json"); + } + + public OpenSynapseConfig Load(ModeSelection? legacySelection = null) + { + if (!File.Exists(path)) + { + var imported = TryImportPowerPilotConfig(); + if (imported is not null) + { + Save(imported); + return imported; + } + var created = new OpenSynapseConfig + { + Selection = legacySelection ?? ModeSelection.Auto + }; + Save(created); + return created; + } + + OpenSynapseConfig config; + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(path), AgentJson.Options) + ?? throw new InvalidDataException("Configuration file is empty."); + } + catch (JsonException ex) + { + throw new InvalidDataException("Configuration file is not valid JSON and was not overwritten.", ex); + } + + if (config.SchemaVersion > OpenSynapseConfig.CurrentSchemaVersion) + throw new NotSupportedException( + $"Configuration schema {config.SchemaVersion} is newer than supported schema {OpenSynapseConfig.CurrentSchemaVersion}."); + var requiresMigration = config.SchemaVersion < OpenSynapseConfig.CurrentSchemaVersion; + config.SchemaVersion = OpenSynapseConfig.CurrentSchemaVersion; + config.QuietWakeDeviceNames ??= []; + config.SmartIgnoredFullscreenProcesses ??= []; + config.SmartHyperProcessNames ??= []; + config.SmartBalanceProcessNames ??= []; + config.ApplicationRules ??= []; + config.Validate(); + if (requiresMigration) Save(config); + return config; + } + + private OpenSynapseConfig? TryImportPowerPilotConfig() + { + var defaultPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "config.json"); + if (!string.Equals(Path.GetFullPath(path), Path.GetFullPath(defaultPath), StringComparison.OrdinalIgnoreCase)) + return null; + var legacyPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PowerPilot", + "config.json"); + if (!File.Exists(legacyPath)) return null; + try + { + using var document = JsonDocument.Parse(File.ReadAllText(legacyPath)); + var root = document.RootElement; + var config = new OpenSynapseConfig(); + if (TryGetString(root, "Selection") is { } selection) + { + config.Selection = selection.Equals("Hyper", StringComparison.OrdinalIgnoreCase) + ? ModeSelection.Performance + : selection.Equals("Balance", StringComparison.OrdinalIgnoreCase) + ? ModeSelection.Balanced + : selection.Equals("Quiet", StringComparison.OrdinalIgnoreCase) + ? ModeSelection.Quiet + : ModeSelection.Auto; + } + config.SmartAutomationEnabled = TryGetBool(root, "SmartAutomationEnabled") ?? config.SmartAutomationEnabled; + config.BalancedBatteryThresholdPercent = TryGetInt(root, "BalanceBatteryThreshold") + ?? TryGetInt(root, "BalancedBatteryThresholdPercent") + ?? config.BalancedBatteryThresholdPercent; + config.ManageAdvancedColor = TryGetBool(root, "ManageAdvancedColor") ?? config.ManageAdvancedColor; + config.ManageBrightness = TryGetBool(root, "ManageBrightness") ?? config.ManageBrightness; + config.ManageDisplayScaling = TryGetBool(root, "DisplayScalingEnabled") ?? config.ManageDisplayScaling; + config.InternalDisplayScalePercent = TryGetInt(root, "InternalScale") ?? config.InternalDisplayScalePercent; + config.ExternalDisplayScalePercent = TryGetInt(root, "ExternalScale") ?? config.ExternalDisplayScalePercent; + config.BalancedBrightnessPercent = TryGetInt(root, "BalanceBrightness") ?? config.BalancedBrightnessPercent; + config.QuietBrightnessPercent = TryGetInt(root, "QuietBrightness") ?? config.QuietBrightnessPercent; + config.QuietRefreshRateHz = TryGetInt(root, "QuietRefreshRate") ?? config.QuietRefreshRateHz; + config.BalancedRefreshRateHz = TryGetInt(root, "BalanceRefreshRate") ?? config.BalancedRefreshRateHz; + if (TryGetString(root, "RefreshPolicy") is { } refresh) + { + config.RefreshPolicy = refresh switch + { + "DynamicNative" => RefreshPolicy.DynamicNative, + "Fixed60" => RefreshPolicy.Fixed60, + "Fixed120" => RefreshPolicy.Fixed120, + "Fixed240" => RefreshPolicy.Fixed240, + "Unmanaged" => RefreshPolicy.Unmanaged, + _ => RefreshPolicy.FollowMode + }; + } + config.SmartHighPowerCpuEnter = TryGetInt(root, "SmartHighPowerCpuEnter") ?? config.SmartHighPowerCpuEnter; + config.SmartHighPowerCpuExit = TryGetInt(root, "SmartHighPowerCpuExit") ?? config.SmartHighPowerCpuExit; + config.SmartPortableCpuEnter = TryGetInt(root, "SmartPortableCpuEnter") ?? config.SmartPortableCpuEnter; + config.SmartPortableCpuExit = TryGetInt(root, "SmartPortableCpuExit") ?? config.SmartPortableCpuExit; + config.SmartLoadEnterSamples = TryGetInt(root, "SmartLoadEnterSamples") ?? config.SmartLoadEnterSamples; + config.SmartAppEnterSamples = TryGetInt(root, "SmartAppEnterSamples") ?? config.SmartAppEnterSamples; + config.SmartExitSamples = TryGetInt(root, "SmartExitSamples") ?? config.SmartExitSamples; + config.SmartMinimumDwellSeconds = TryGetInt(root, "SmartMinimumDwellSeconds") ?? config.SmartMinimumDwellSeconds; + config.SmartAppCpuFloor = TryGetInt(root, "SmartAppCpuFloor") ?? config.SmartAppCpuFloor; + config.SmartGpuEnter = TryGetInt(root, "SmartGpuEnter") ?? config.SmartGpuEnter; + config.SmartGpuExit = TryGetInt(root, "SmartGpuExit") ?? config.SmartGpuExit; + ImportNames(root, "SmartHyperProcessNames", config.SmartHyperProcessNames); + ImportNames(root, "SmartBalanceProcessNames", config.SmartBalanceProcessNames); + ImportRules(root, config.ApplicationRules); + config.SchemaVersion = OpenSynapseConfig.CurrentSchemaVersion; + config.Validate(); + return config; + } + catch { return null; } + } + + private static void ImportNames(JsonElement root, string property, List destination) + { + if (!TryGetProperty(root, property, out var value) || value.ValueKind != JsonValueKind.Array) return; + destination.Clear(); + foreach (var item in value.EnumerateArray()) + if (item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) + destination.Add(item.GetString()!.Trim()); + } + + private static void ImportRules(JsonElement root, List destination) + { + if (!TryGetProperty(root, "ApplicationRules", out var value) || value.ValueKind != JsonValueKind.Array) return; + foreach (var item in value.EnumerateArray()) + { + var process = TryGetString(item, "ProcessName"); + var profile = TryGetString(item, "Profile"); + var scope = TryGetString(item, "Scope"); + if (process is null || profile is null || scope is null) continue; + var mappedProfile = profile.Equals("Hyper", StringComparison.OrdinalIgnoreCase) + ? ApplicationRuleProfile.Performance + : profile.Equals("Balance", StringComparison.OrdinalIgnoreCase) + ? ApplicationRuleProfile.Balanced + : ApplicationRuleProfile.Quiet; + if (!Enum.TryParse(scope, true, out var mappedScope)) continue; + var enabled = TryGetBool(item, "Enabled") ?? true; + destination.Add(new ApplicationRule(process, mappedProfile, mappedScope, enabled)); + } + } + + private static string? TryGetString(JsonElement root, string name) => + TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static int? TryGetInt(JsonElement root, string name) => + TryGetProperty(root, name, out var value) && value.TryGetInt32(out var result) ? result : null; + + private static bool? TryGetBool(JsonElement root, string name) => + TryGetProperty(root, name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False + ? value.GetBoolean() + : null; + + private static bool TryGetProperty(JsonElement root, string name, out JsonElement value) + { + foreach (var property in root.EnumerateObject()) + { + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + value = default; + return false; + } + + public void Save(OpenSynapseConfig config) + { + if (config.SchemaVersion > OpenSynapseConfig.CurrentSchemaVersion) + throw new NotSupportedException( + $"Configuration schema {config.SchemaVersion} is newer than supported schema {OpenSynapseConfig.CurrentSchemaVersion}."); + config.SchemaVersion = OpenSynapseConfig.CurrentSchemaVersion; + config.Validate(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(config, AgentJson.Options)); + File.Move(temporary, path, true); + } +} diff --git a/src/OpenSynapse.Agent/OpenSynapseState.cs b/src/OpenSynapse.Agent/OpenSynapseState.cs index 85a4aa9..189fb48 100644 --- a/src/OpenSynapse.Agent/OpenSynapseState.cs +++ b/src/OpenSynapse.Agent/OpenSynapseState.cs @@ -1,44 +1,77 @@ using System.Text.Json; +using System.Text.Json.Serialization; using OpenSynapse.Core; namespace OpenSynapse.Agent; internal sealed class OpenSynapseState { - public ModeSelection Selection { get; set; } = ModeSelection.Auto; + public const int CurrentSchemaVersion = 10; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + [JsonPropertyName("selection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModeSelection? LegacySelection { get; set; } public string? OriginalPowerPlan { get; set; } public string? PerformancePowerPlan { get; set; } + public string? BalancedPowerPlan { get; set; } public string? QuietPowerPlan { get; set; } public OperatingMode? ActiveMode { get; set; } public int? OriginalBrightness { get; set; } public List AdvancedColors { get; set; } = []; + public List DisplayScales { get; set; } = []; + public List DisabledWakeDevices { get; set; } = []; + public SmartAutomationState SmartAutomation { get; set; } = new(); + public TemporaryModeState? TemporaryMode { get; set; } } internal sealed record AdvancedColorState(string Key, bool Enabled); +internal sealed record DisplayScaleState(string Key, int ScalePercent); internal sealed class StateStore { - private readonly string path = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "OpenSynapse", - "state.json"); + private readonly string path; + + public StateStore(string? path = null) + { + this.path = path ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "state.json"); + } public OpenSynapseState Load() { if (!File.Exists(path)) return new OpenSynapseState(); try { - return JsonSerializer.Deserialize(File.ReadAllText(path), AgentJson.Options) + var state = JsonSerializer.Deserialize(File.ReadAllText(path), AgentJson.Options) ?? new OpenSynapseState(); + if (state.SchemaVersion > OpenSynapseState.CurrentSchemaVersion) + throw new NotSupportedException( + $"State schema {state.SchemaVersion} is newer than supported schema {OpenSynapseState.CurrentSchemaVersion}."); + var requiresMigration = state.SchemaVersion < OpenSynapseState.CurrentSchemaVersion; + state.SchemaVersion = OpenSynapseState.CurrentSchemaVersion; + state.AdvancedColors ??= []; + state.DisplayScales ??= []; + state.DisabledWakeDevices ??= []; + state.SmartAutomation ??= new SmartAutomationState(); + state.SmartAutomation.DgpuConsumers ??= []; + if (requiresMigration) Save(state); + return state; } - catch (JsonException) + catch (JsonException ex) { - return new OpenSynapseState(); + throw new InvalidDataException("Captured state is not valid JSON and was not overwritten.", ex); } } public void Save(OpenSynapseState state) { + if (state.SchemaVersion > OpenSynapseState.CurrentSchemaVersion) + throw new NotSupportedException( + $"State schema {state.SchemaVersion} is newer than supported schema {OpenSynapseState.CurrentSchemaVersion}."); + state.SchemaVersion = OpenSynapseState.CurrentSchemaVersion; Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temporary = path + ".tmp"; File.WriteAllText(temporary, JsonSerializer.Serialize(state, AgentJson.Options)); diff --git a/src/OpenSynapse.Agent/PowerPlanManager.cs b/src/OpenSynapse.Agent/PowerPlanManager.cs index e1d211d..85ac919 100644 --- a/src/OpenSynapse.Agent/PowerPlanManager.cs +++ b/src/OpenSynapse.Agent/PowerPlanManager.cs @@ -3,35 +3,87 @@ namespace OpenSynapse.Agent; +internal readonly record struct PowerPlanValues(int Ac, int Dc); + +internal sealed record PowerPlanSetting( + string Subgroup, + string Setting, + PowerPlanValues Performance, + PowerPlanValues Balanced, + PowerPlanValues Quiet, + bool Optional, + OperatingMode? OnlyMode = null) +{ + public PowerPlanValues GetValues(OperatingMode mode) => mode switch + { + OperatingMode.Performance => Performance, + OperatingMode.Balanced => Balanced, + OperatingMode.Quiet => Quiet, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported operating mode.") + }; +} + internal sealed partial class PowerPlanManager { - private const string Balanced = "381b4222-f694-41f0-9685-ff5bb260df2e"; - private readonly string powerCfg = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + private const string BalancedScheme = "381b4222-f694-41f0-9685-ff5bb260df2e"; + private const string PerformancePlanName = "OpenSynapse Performance"; + private const string BalancedPlanName = "OpenSynapse Balanced"; + private const string QuietPlanName = "OpenSynapse Quiet"; + private readonly Func, string> run; - private static readonly (string Subgroup, string Setting, int PerfAc, int PerfDc, int QuietAc, int QuietDc, bool Optional)[] Settings = + internal static IReadOnlyList PolicySettings { get; } = [ - ("54533251-82be-4824-96c1-47b60b740d00", "893dee8e-2bef-41e0-89c6-b55d0929964c", 5, 5, 5, 5, false), - ("54533251-82be-4824-96c1-47b60b740d00", "bc5038f7-23e0-4960-96da-33abaf5935ec", 100, 100, 80, 80, false), - ("54533251-82be-4824-96c1-47b60b740d00", "36687f9e-e3a5-4dbf-b1dc-15eb381c6863", 0, 20, 90, 90, true), - ("54533251-82be-4824-96c1-47b60b740d00", "be337238-0d82-4146-a960-4f3749d470c7", 2, 2, 0, 0, true), - ("54533251-82be-4824-96c1-47b60b740d00", "94d3a615-a899-4ac5-ae2b-e4d8f634367f", 1, 1, 0, 0, true), - ("19cbb8fa-5279-450e-9fac-8a3d5fedd0c1", "12bbebe6-58d6-4636-95bb-3217ef867c1a", 0, 1, 3, 3, true), - ("501a4d13-42af-4429-9fd1-a8218c268e20", "ee12f906-d277-404b-b6da-e5fa1a576df5", 0, 1, 2, 2, true), - ("7516b95f-f776-4464-8c53-06167f40cc99", "3c0bc021-c8a8-4e07-a973-6b14cbcb2b7e", 0, 300, 300, 120, false), - ("2a737441-1930-4402-8d77-b2bebba308a3", "48e6b7a6-50f5-4782-a5d4-53bb8f07e226", 0, 1, 1, 1, true), - ("de830923-a562-41af-a086-e3a2c6bad2da", "e69653ca-cf7f-4f05-aa73-cb833fa90ad4", 0, 20, 0, 100, true), - ("238c9fa8-0aad-41ed-83f4-97be242c8f20", "29f6c1db-86da-48c5-9fdb-f2b67b1f44da", 0, 900, 600, 180, false), - ("238c9fa8-0aad-41ed-83f4-97be242c8f20", "9d7815a6-7ee4-497e-8888-515a05f02364", 0, 3600, 1800, 900, false), - ("4f971e89-eebd-4455-a8de-9e59040e7347", "5ca83367-6e45-459f-a27b-476b1d01c936", 1, 1, 1, 2, false) + new("54533251-82be-4824-96c1-47b60b740d00", "893dee8e-2bef-41e0-89c6-b55d0929964c", new(5, 5), new(5, 5), new(5, 5), false), + new("54533251-82be-4824-96c1-47b60b740d00", "bc5038f7-23e0-4960-96da-33abaf5935ec", new(100, 100), new(100, 100), new(80, 75), false), + new("54533251-82be-4824-96c1-47b60b740d00", "36687f9e-e3a5-4dbf-b1dc-15eb381c6863", new(0, 20), new(50, 70), new(90, 95), true), + new("54533251-82be-4824-96c1-47b60b740d00", "be337238-0d82-4146-a960-4f3749d470c7", new(2, 2), new(3, 3), new(0, 0), true), + new("54533251-82be-4824-96c1-47b60b740d00", "94d3a615-a899-4ac5-ae2b-e4d8f634367f", new(1, 1), new(1, 0), new(0, 0), true), + new("19cbb8fa-5279-450e-9fac-8a3d5fedd0c1", "12bbebe6-58d6-4636-95bb-3217ef867c1a", new(0, 1), new(1, 2), new(3, 3), true), + new("501a4d13-42af-4429-9fd1-a8218c268e20", "ee12f906-d277-404b-b6da-e5fa1a576df5", new(0, 1), new(1, 2), new(2, 2), true), + new("7516b95f-f776-4464-8c53-06167f40cc99", "3c0bc021-c8a8-4e07-a973-6b14cbcb2b7e", new(900, 300), new(600, 300), new(300, 120), false), + new("2a737441-1930-4402-8d77-b2bebba308a3", "48e6b7a6-50f5-4782-a5d4-53bb8f07e226", new(0, 1), new(1, 1), new(1, 1), true), + new("de830923-a562-41af-a086-e3a2c6bad2da", "e69653ca-cf7f-4f05-aa73-cb833fa90ad4", new(0, 20), new(0, 50), new(0, 100), true), + new("238c9fa8-0aad-41ed-83f4-97be242c8f20", "29f6c1db-86da-48c5-9fdb-f2b67b1f44da", new(0, 900), new(900, 600), new(600, 180), false), + new("238c9fa8-0aad-41ed-83f4-97be242c8f20", "9d7815a6-7ee4-497e-8888-515a05f02364", new(0, 3600), new(3600, 1800), new(1800, 900), false), + new("4f971e89-eebd-4455-a8de-9e59040e7347", "5ca83367-6e45-459f-a27b-476b1d01c936", new(1, 1), new(1, 2), new(1, 2), false), + new("54533251-82be-4824-96c1-47b60b740d00", "893dee8e-2bef-41e0-89c6-b55d0929964d", new(5, 5), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "893dee8e-2bef-41e0-89c6-b55d0929964e", new(5, 5), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "bc5038f7-23e0-4960-96da-33abaf5935ed", new(100, 100), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "bc5038f7-23e0-4960-96da-33abaf5935ee", new(100, 100), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "45bcc044-d885-43e2-8605-ee0ec6e96b59", new(100, 100), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "8baa4a8a-14c6-4451-8e8b-14bdbd197537", new(1, 1), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "465e1f50-b610-473a-ab58-00d1077dc418", new(2, 2), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "465e1f50-b610-473a-ab58-00d1077dc419", new(3, 3), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "0cc5b647-c1df-4637-891a-dec35c318583", new(100, 100), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("54533251-82be-4824-96c1-47b60b740d00", "0cc5b647-c1df-4637-891a-dec35c318584", new(0, 0), new(0, 0), new(0, 0), true, OperatingMode.Performance), + new("238c9fa8-0aad-41ed-83f4-97be242c8f20", "bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d", new(0, 0), new(0, 0), new(0, 0), true, OperatingMode.Quiet), + new("fea3413e-7e05-4911-9a71-700331f1c294", "f15576e8-98b7-4186-b944-eafa664402d9", new(0, 0), new(0, 0), new(0, 0), true, OperatingMode.Quiet) ]; + public PowerPlanManager() + { + var powerCfg = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + run = arguments => ProcessRunner.Run(powerCfg, arguments.ToArray()); + } + + internal PowerPlanManager(Func, string> run) + { + this.run = run; + } + public string GetActiveGuid() => ParseGuid(Run("/getactivescheme")); public string Apply(OperatingMode mode, OpenSynapseState state) { state.OriginalPowerPlan ??= GetActiveGuid(); EnsurePlans(state); - var target = mode == OperatingMode.Performance ? state.PerformancePowerPlan! : state.QuietPowerPlan!; + var target = mode switch + { + OperatingMode.Performance => state.PerformancePowerPlan!, + OperatingMode.Balanced => state.BalancedPowerPlan!, + OperatingMode.Quiet => state.QuietPowerPlan!, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported operating mode.") + }; Run("/setactive", target); var active = GetActiveGuid(); if (!active.Equals(target, StringComparison.OrdinalIgnoreCase)) @@ -39,6 +91,34 @@ public string Apply(OperatingMode mode, OpenSynapseState state) return active; } + public void ApplyQuietCpuMax(OpenSynapseState state, int dcMaximumPercent) + { + if (state.QuietPowerPlan is null) + throw new InvalidOperationException("Quiet power plan has not been created."); + if (dcMaximumPercent is < 1 or > 100) + throw new ArgumentOutOfRangeException(nameof(dcMaximumPercent)); + Run("/setdcvalueindex", state.QuietPowerPlan, + "54533251-82be-4824-96c1-47b60b740d00", + "bc5038f7-23e0-4960-96da-33abaf5935ec", + dcMaximumPercent.ToString()); + } + + public void ApplyHyperCpuPolicy(OpenSynapseState state, HyperCpuPolicy policy) + { + if (state.PerformancePowerPlan is null) + throw new InvalidOperationException("Hyper power plan has not been created."); + var minimum = policy == HyperCpuPolicy.Latency ? 100 : 5; + var minimum1 = policy == HyperCpuPolicy.Latency ? 100 : 5; + var minimum2 = policy == HyperCpuPolicy.Latency ? 100 : 5; + var parked = policy == HyperCpuPolicy.Latency ? 100 : 0; + var subgroup = "54533251-82be-4824-96c1-47b60b740d00"; + SetPair(state.PerformancePowerPlan, subgroup, "893dee8e-2bef-41e0-89c6-b55d0929964c", minimum); + SetPair(state.PerformancePowerPlan, subgroup, "893dee8e-2bef-41e0-89c6-b55d0929964d", minimum1); + SetPair(state.PerformancePowerPlan, subgroup, "893dee8e-2bef-41e0-89c6-b55d0929964e", minimum2); + SetPair(state.PerformancePowerPlan, subgroup, "0cc5b647-c1df-4637-891a-dec35c318583", 100); + SetPair(state.PerformancePowerPlan, subgroup, "0cc5b647-c1df-4637-891a-dec35c318584", parked); + } + public void Restore(OpenSynapseState state) { if (string.IsNullOrWhiteSpace(state.OriginalPowerPlan)) return; @@ -51,17 +131,31 @@ public void Restore(OpenSynapseState state) state.OriginalPowerPlan = null; } + public void DeleteManagedPlans(OpenSynapseState state) + { + var active = GetActiveGuid(); + state.PerformancePowerPlan = DeleteManagedPlan(state.PerformancePowerPlan, active, PerformancePlanName); + state.BalancedPowerPlan = DeleteManagedPlan(state.BalancedPowerPlan, active, BalancedPlanName); + state.QuietPowerPlan = DeleteManagedPlan(state.QuietPowerPlan, active, QuietPlanName); + } + private void EnsurePlans(OpenSynapseState state) { - if (!Exists(state.PerformancePowerPlan)) + if (!IsManagedPlan(state.PerformancePowerPlan, PerformancePlanName)) { - var plan = Duplicate("OpenSynapse Performance"); + var plan = Duplicate(PerformancePlanName); Configure(plan, OperatingMode.Performance); state.PerformancePowerPlan = plan; } - if (!Exists(state.QuietPowerPlan)) + if (!IsManagedPlan(state.BalancedPowerPlan, BalancedPlanName)) { - var plan = Duplicate("OpenSynapse Quiet"); + var plan = Duplicate(BalancedPlanName); + Configure(plan, OperatingMode.Balanced); + state.BalancedPowerPlan = plan; + } + if (!IsManagedPlan(state.QuietPowerPlan, QuietPlanName)) + { + var plan = Duplicate(QuietPlanName); Configure(plan, OperatingMode.Quiet); state.QuietPowerPlan = plan; } @@ -69,21 +163,21 @@ private void EnsurePlans(OpenSynapseState state) private string Duplicate(string name) { - var guid = ParseGuid(Run("/duplicatescheme", Balanced)); + var guid = ParseGuid(Run("/duplicatescheme", BalancedScheme)); Run("/changename", guid, name, "Managed by OpenSynapse"); return guid; } private void Configure(string guid, OperatingMode mode) { - foreach (var setting in Settings) + foreach (var setting in PolicySettings) { - var ac = mode == OperatingMode.Performance ? setting.PerfAc : setting.QuietAc; - var dc = mode == OperatingMode.Performance ? setting.PerfDc : setting.QuietDc; + if (setting.OnlyMode is not null && setting.OnlyMode != mode) continue; + var values = setting.GetValues(mode); try { - Run("/setacvalueindex", guid, setting.Subgroup, setting.Setting, ac.ToString()); - Run("/setdcvalueindex", guid, setting.Subgroup, setting.Setting, dc.ToString()); + Run("/setacvalueindex", guid, setting.Subgroup, setting.Setting, values.Ac.ToString()); + Run("/setdcvalueindex", guid, setting.Subgroup, setting.Setting, values.Dc.ToString()); } catch when (setting.Optional) { } } @@ -92,7 +186,39 @@ private void Configure(string guid, OperatingMode mode) private bool Exists(string? guid) => !string.IsNullOrWhiteSpace(guid) && Run("/list").Contains(guid, StringComparison.OrdinalIgnoreCase); - private string Run(params string[] arguments) => ProcessRunner.Run(powerCfg, arguments); + private bool IsManagedPlan(string? guid, string expectedName) + { + if (string.IsNullOrWhiteSpace(guid)) return false; + return Run("/list") + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Any(line => line.Contains(guid, StringComparison.OrdinalIgnoreCase) + && line.Contains($"({expectedName})", StringComparison.OrdinalIgnoreCase)); + } + + private string? DeleteManagedPlan(string? guid, string active, string expectedName) + { + if (!Exists(guid)) return null; + if (!IsManagedPlan(guid, expectedName)) + throw new InvalidOperationException( + $"Power plan {guid} is not marked as {expectedName}; refusing to delete it."); + if (guid!.Equals(active, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Cannot delete active managed power plan {guid}."); + Run("/delete", guid); + if (Exists(guid)) throw new InvalidOperationException($"Managed power plan {guid} was not deleted."); + return null; + } + + private string Run(params string[] arguments) => run(arguments); + + private void SetPair(string plan, string subgroup, string setting, int value) + { + try + { + Run("/setacvalueindex", plan, subgroup, setting, value.ToString()); + Run("/setdcvalueindex", plan, subgroup, setting, value.ToString()); + } + catch { } + } private static string ParseGuid(string text) { diff --git a/src/OpenSynapse.Agent/PowerSupplyProbe.cs b/src/OpenSynapse.Agent/PowerSupplyProbe.cs new file mode 100644 index 0000000..e729ed8 --- /dev/null +++ b/src/OpenSynapse.Agent/PowerSupplyProbe.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal sealed record SystemPowerSnapshot(PowerSource Source, int? BatteryPercent); + +internal sealed class PowerSupplyProbe +{ + private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(5); + private readonly Func readSystemPower; + private readonly Func readAdapterLimit; + private readonly Func getCurrentTime; + private readonly TimeSpan cacheDuration; + private bool hasCachedAdapterLimit; + private double? cachedAdapterLimit; + private DateTimeOffset cachedAt; + + public PowerSupplyProbe() + : this(ReadWindowsPower, ReadNvidiaAdapterLimit, () => DateTimeOffset.UtcNow, DefaultCacheDuration) + { + } + + internal PowerSupplyProbe( + Func readSystemPower, + Func readAdapterLimit, + Func getCurrentTime, + TimeSpan cacheDuration) + { + this.readSystemPower = readSystemPower; + this.readAdapterLimit = readAdapterLimit; + this.getCurrentTime = getCurrentTime; + this.cacheDuration = cacheDuration; + } + + public PowerSnapshot GetSnapshot() + { + var systemPower = readSystemPower(); + if (systemPower.Source != PowerSource.Ac) + { + hasCachedAdapterLimit = false; + cachedAdapterLimit = null; + return new PowerSnapshot( + systemPower.Source, + SupplyClassifier.Resolve(systemPower.Source, null), + systemPower.BatteryPercent); + } + + var now = getCurrentTime(); + if (!hasCachedAdapterLimit || now - cachedAt >= cacheDuration) + { + try { cachedAdapterLimit = readAdapterLimit(); } + catch { cachedAdapterLimit = null; } + cachedAt = now; + hasCachedAdapterLimit = true; + } + + return new PowerSnapshot( + systemPower.Source, + SupplyClassifier.Resolve(systemPower.Source, cachedAdapterLimit), + systemPower.BatteryPercent, + cachedAdapterLimit); + } + + internal static double? ParseAdapterLimit(string output) + { + var firstLine = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + return double.TryParse(firstLine?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var watts) + && double.IsFinite(watts) + ? watts + : null; + } + + private static SystemPowerSnapshot ReadWindowsPower() + { + var status = System.Windows.Forms.SystemInformation.PowerStatus; + var source = status.PowerLineStatus switch + { + System.Windows.Forms.PowerLineStatus.Online => PowerSource.Ac, + System.Windows.Forms.PowerLineStatus.Offline => PowerSource.Battery, + _ => PowerSource.Unknown + }; + var fraction = status.BatteryLifePercent; + var percent = float.IsFinite(fraction) && fraction is >= 0 and <= 1 + ? (int?)Math.Round(fraction * 100) + : null; + return new SystemPowerSnapshot(source, percent); + } + + private static double? ReadNvidiaAdapterLimit() + { + try + { + var output = ProcessRunner.Run( + "nvidia-smi.exe", + "--query-gpu=enforced.power.limit", + "--format=csv,noheader,nounits"); + return ParseAdapterLimit(output); + } + catch + { + return null; + } + } +} diff --git a/src/OpenSynapse.Agent/Program.cs b/src/OpenSynapse.Agent/Program.cs index 92ba307..6625221 100644 --- a/src/OpenSynapse.Agent/Program.cs +++ b/src/OpenSynapse.Agent/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Security.Principal; using OpenSynapse.Agent; using OpenSynapse.Core; @@ -11,6 +12,13 @@ var controller = new AgentController(); if (args.Length == 0 || args[0].Equals("serve", StringComparison.OrdinalIgnoreCase)) { + var user = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName; + using var instance = SingleInstanceLease.TryAcquire($"Local\\OpenSynapse.Agent.{user.Replace('\\', '_')}"); + if (instance is null) + { + Console.Error.WriteLine("OpenSynapse.Agent is already running for this user."); + return 2; + } await new AgentServer(controller).RunAsync(CancellationToken.None); return 0; } @@ -18,18 +26,44 @@ var request = args[0].ToLowerInvariant() switch { "status" => new AgentRequest(AgentOperation.Status), + "self-test" => new AgentRequest(AgentOperation.SelfTest), "devices" => new AgentRequest(AgentOperation.ListDevices), "restore" => new AgentRequest(AgentOperation.Restore), - "apply" when args.Length == 2 && Enum.TryParse(args[1], true, out var mode) + "uninstall-cleanup" => new AgentRequest(AgentOperation.UninstallCleanup), + "apply" when args.Length == 2 + && TryParseRequestedMode(args[1], out var mode) => new AgentRequest(AgentOperation.Apply, mode), "mouse-dpi" when args.Length == 2 && int.TryParse(args[1], out var dpi) => new AgentRequest(AgentOperation.SetMouseDpi, DpiX: dpi, DpiY: dpi), "mouse-polling" when args.Length == 2 && int.TryParse(args[1], out var polling) => new AgentRequest(AgentOperation.SetMousePollingRate, PollingRate: polling), _ => throw new ArgumentException( - "Usage: OpenSynapse.Agent [serve|status|devices|apply |restore|mouse-dpi <100..30000>|mouse-polling <125|500|1000>]") + "Usage: OpenSynapse.Agent [serve|status|self-test|devices|apply |restore|uninstall-cleanup|mouse-dpi <100..30000>|mouse-polling <125|500|1000>]") }; var response = await controller.HandleAsync(request); Console.WriteLine(JsonSerializer.Serialize(response, AgentJson.Options)); return response.Success ? 0 : 1; + +static bool TryParseRequestedMode(string value, out OperatingMode mode) +{ + if (value.Equals("Hyper", StringComparison.OrdinalIgnoreCase) + || value.Equals("Performance", StringComparison.OrdinalIgnoreCase)) + { + mode = OperatingMode.Performance; + return true; + } + if (value.Equals("Balance", StringComparison.OrdinalIgnoreCase) + || value.Equals("Balanced", StringComparison.OrdinalIgnoreCase)) + { + mode = OperatingMode.Balanced; + return true; + } + if (value.Equals("Quiet", StringComparison.OrdinalIgnoreCase)) + { + mode = OperatingMode.Quiet; + return true; + } + mode = default; + return false; +} diff --git a/src/OpenSynapse.Agent/Properties/AssemblyInfo.cs b/src/OpenSynapse.Agent/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d38d9d8 --- /dev/null +++ b/src/OpenSynapse.Agent/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenSynapse.Agent.Tests")] diff --git a/src/OpenSynapse.Agent/TelemetryHistoryWriter.cs b/src/OpenSynapse.Agent/TelemetryHistoryWriter.cs new file mode 100644 index 0000000..f170fb6 --- /dev/null +++ b/src/OpenSynapse.Agent/TelemetryHistoryWriter.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal sealed class TelemetryHistoryWriter +{ + private const long RotateAtBytes = 2 * 1024 * 1024; + private readonly string path; + private readonly Func now; + private DateTimeOffset lastWrite = DateTimeOffset.MinValue; + + public TelemetryHistoryWriter(string? path = null, Func? now = null) + { + this.path = path ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenSynapse", + "telemetry.jsonl"); + this.now = now ?? (() => DateTimeOffset.UtcNow); + } + + public string FilePath => path; + + public bool TryWrite( + OpenSynapseState state, + OpenSynapseConfig config, + PowerSnapshot power, + TelemetrySnapshot telemetry) + { + var timestamp = now(); + if (timestamp - lastWrite < TimeSpan.FromSeconds(30)) return true; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (File.Exists(path) && new FileInfo(path).Length > RotateAtBytes) + File.Move(path, path + ".old", true); + + var smart = state.SmartAutomation ?? new SmartAutomationState(); + var record = new + { + Timestamp = timestamp, + Source = power.Source, + SupplyType = power.SupplyType, + BatteryPercent = power.BatteryPercent, + AdapterLimitWatts = power.AdapterLimitWatts, + BatteryDischargeWatts = telemetry.BatteryDischargeWatts, + BatteryDischargeEmaWatts = telemetry.BatteryDischargeEmaWatts, + BatteryDischargeAverage10mWatts = telemetry.BatteryDischargeAverage10mWatts, + BatteryChargeWatts = telemetry.BatteryChargeWatts, + BatteryRemainingMwh = telemetry.BatteryRemainingMwh, + BatteryVoltageMv = telemetry.BatteryVoltageMv, + BatteryEstimateConfidence = telemetry.Confidence, + EstimatedHours = telemetry.EstimatedHours, + Selection = config.Selection, + TemporaryMode = state.TemporaryMode, + ActiveMode = state.ActiveMode, + CpuPercent = telemetry.CpuPercent, + GpuPercent = telemetry.GpuPercent, + GpuAvailable = telemetry.GpuAvailable, + GpuError = telemetry.GpuError, + DgpuPercent = telemetry.DgpuPercent, + DgpuDedicatedMb = telemetry.DgpuDedicatedMb, + DgpuActivitySuspected = smart.DgpuActivitySuspected, + DgpuActivityConfidence = smart.DgpuActivityConfidence, + DgpuConsumers = smart.DgpuConsumers.Select(item => item.ProcessName).Distinct(StringComparer.OrdinalIgnoreCase), + ForegroundProcess = telemetry.ForegroundProcess, + ForegroundFullscreen = telemetry.ForegroundFullscreen, + SessionLocked = telemetry.SessionLocked, + SmartReason = smart.LastReason, + SmartMatchedRule = smart.MatchedRule + }; + File.AppendAllText( + path, + JsonSerializer.Serialize(record, AgentJson.Options) + Environment.NewLine, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + lastWrite = timestamp; + return true; + } + catch { return false; } + } +} diff --git a/src/OpenSynapse.Agent/TelemetryProbe.cs b/src/OpenSynapse.Agent/TelemetryProbe.cs new file mode 100644 index 0000000..46390db --- /dev/null +++ b/src/OpenSynapse.Agent/TelemetryProbe.cs @@ -0,0 +1,571 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal sealed class TelemetryProbe : IDisposable +{ + private readonly object gate = new(); + private readonly GpuTelemetryProbe gpu = new(); + private long previousIdle; + private long previousKernel; + private long previousUser; + private bool hasPreviousCpu; + private PowerSource trendSource = PowerSource.Unknown; + private DateTimeOffset? lastTrendSampleAt; + private double? dischargeEmaWatts; + private readonly List batterySamples = []; + + public TelemetrySnapshot Read(bool includeRunningProcesses = false) + { + lock (gate) + { + var cpu = ReadCpuPercent(); + var foreground = ReadForeground(); + var battery = ReadBattery(); + var gpuSample = gpu.ReadLatest(); + var running = includeRunningProcesses ? ReadRunningProcesses() : null; + return foreground with + { + CpuPercent = cpu, + GpuPercent = gpuSample.Available ? gpuSample.TotalUtilizationPercent : -1, + GpuAvailable = gpuSample.Available, + DgpuPercent = gpuSample.Available ? gpuSample.DiscreteUtilizationPercent : -1, + DgpuDedicatedMb = gpuSample.Available + ? Math.Round(gpuSample.DiscreteDedicatedBytes / 1024d / 1024d, 1) + : -1, + DgpuConsumers = gpuSample.Consumers, + GpuError = gpuSample.Error, + BatteryDischargeWatts = battery.DischargeWatts, + BatteryChargeWatts = battery.ChargeWatts, + BatteryRemainingMwh = battery.RemainingMwh, + BatteryVoltageMv = battery.VoltageMv, + EstimatedHours = battery.EstimatedHours, + Confidence = battery.Confidence, + BatteryDischargeEmaWatts = battery.DischargeEmaWatts, + BatteryDischargeAverage10mWatts = battery.DischargeAverage10mWatts, + RunningProcesses = running + }; + } + } + + public void Dispose() + { + gpu.Dispose(); + } + + private double ReadCpuPercent() + { + if (!GetSystemTimes(out var idle, out var kernel, out var user)) return -1; + var idleTicks = ToInt64(idle); + var kernelTicks = ToInt64(kernel); + var userTicks = ToInt64(user); + if (!hasPreviousCpu) + { + previousIdle = idleTicks; + previousKernel = kernelTicks; + previousUser = userTicks; + hasPreviousCpu = true; + return -1; + } + + var idleDelta = idleTicks - previousIdle; + var kernelDelta = kernelTicks - previousKernel; + var userDelta = userTicks - previousUser; + previousIdle = idleTicks; + previousKernel = kernelTicks; + previousUser = userTicks; + var total = kernelDelta + userDelta; + return total <= 0 + ? -1 + : Math.Clamp((1d - (double)Math.Max(0, idleDelta) / total) * 100d, 0, 100); + } + + private static TelemetrySnapshot ReadForeground() + { + var window = GetForegroundWindow(); + if (window == IntPtr.Zero) + return new TelemetrySnapshot(-1, -1, null, false, false); + + GetWindowThreadProcessId(window, out var processId); + string? processName = null; + try { processName = Process.GetProcessById((int)processId).ProcessName; } + catch { } + + var sample = ReadForegroundWindow(window); + var locked = processName is "LockApp" or "LogonUI"; + return new TelemetrySnapshot(-1, -1, processName, sample.IsFullscreen, locked); + } + + private static ForegroundWindowSample ReadForegroundWindow(IntPtr window) + { + var sample = new ForegroundWindowSample(); + if (IsIconic(window)) sample.IsMinimized = true; + try + { + if (DwmGetWindowAttribute(window, 14, out var cloaked, sizeof(int)) == 0) + sample.IsCloaked = cloaked != 0; + } + catch (DllNotFoundException) { } + catch (EntryPointNotFoundException) { } + + if (sample.IsMinimized || sample.IsCloaked + || !GetWindowRect(window, out var windowRect)) return sample; + var monitor = MonitorFromWindow(window, MonitorDefaultToNearest); + if (monitor == IntPtr.Zero) return sample; + var info = new MonitorInfo { CbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(monitor, ref info)) return sample; + const int tolerance = 3; + sample.IsFullscreen = windowRect.Left <= info.Monitor.Left + tolerance + && windowRect.Top <= info.Monitor.Top + tolerance + && windowRect.Right >= info.Monitor.Right - tolerance + && windowRect.Bottom >= info.Monitor.Bottom - tolerance; + return sample; + } + + private static IReadOnlyList ReadRunningProcesses() + { + try + { + return Process.GetProcesses() + .Select(process => + { + try { return process.ProcessName; } + catch { return string.Empty; } + finally { process.Dispose(); } + }) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(512) + .ToArray(); + } + catch { return []; } + } + + private BatterySample ReadBattery() + { + var source = ReadPowerSource(); + var sample = BatteryClassTelemetry.Read(); + if (sample.Available) + { + var discharge = sample.DischargeWatts > 0 ? (double?)sample.DischargeWatts : null; + UpdateBatteryTrend(source, discharge); + var estimate = discharge is > 0 && sample.RemainingCapacityMwh > 0 + ? sample.RemainingCapacityMwh / (discharge.Value * 1000d) + : (double?)null; + return new BatterySample( + source, + discharge, + sample.ChargeWatts > 0 ? sample.ChargeWatts : null, + sample.RemainingCapacityMwh > 0 ? sample.RemainingCapacityMwh : null, + sample.VoltageMv > 0 ? sample.VoltageMv : null, + estimate, + "Windows Battery Class API", + dischargeEmaWatts, + GetAverage10m()); + } + + var fallback = ReadSystemBatteryState(source); + UpdateBatteryTrend(source, fallback.DischargeWatts); + return fallback with + { + DischargeEmaWatts = dischargeEmaWatts, + DischargeAverage10mWatts = GetAverage10m() + }; + } + + private static BatterySample ReadSystemBatteryState(PowerSource source) + { + try + { + var size = Marshal.SizeOf(); + var buffer = Marshal.AllocHGlobal(size); + try + { + var status = CallNtPowerInformation(5, IntPtr.Zero, 0, buffer, (uint)size); + if (status != 0) return BatterySample.Unavailable(source); + var battery = Marshal.PtrToStructure(buffer); + if (battery.BatteryPresent == 0) return BatterySample.Unavailable(source); + var watts = Math.Abs(battery.Rate) / 1000d; + var remainingMwh = (double)battery.RemainingCapacity; + var estimate = battery.Discharging != 0 && watts > 0 + ? remainingMwh / (watts * 1000d) + : (double?)null; + return new BatterySample( + source, + battery.Discharging != 0 && watts > 0 ? watts : null, + battery.Charging != 0 && watts > 0 ? watts : null, + remainingMwh, + null, + estimate, + "Windows SystemBatteryState", + null, + null); + } + finally { Marshal.FreeHGlobal(buffer); } + } + catch { return BatterySample.Unavailable(source); } + } + + private void UpdateBatteryTrend(PowerSource source, double? dischargeWatts) + { + var now = DateTimeOffset.UtcNow; + if (source != trendSource) + { + trendSource = source; + batterySamples.Clear(); + dischargeEmaWatts = null; + lastTrendSampleAt = null; + } + + if (source != PowerSource.Battery || dischargeWatts is not > 0 || !double.IsFinite(dischargeWatts.Value)) + return; + + var sample = new BatteryTrendSample(now, dischargeWatts.Value); + batterySamples.Add(sample); + var cutoff = now - TimeSpan.FromMinutes(10); + batterySamples.RemoveAll(item => item.Timestamp < cutoff); + if (dischargeEmaWatts is null || lastTrendSampleAt is null) + dischargeEmaWatts = sample.Watts; + else + { + var elapsed = Math.Clamp((now - lastTrendSampleAt.Value).TotalSeconds, 0.1, 300); + var alpha = 1 - Math.Exp(-elapsed / 120d); + dischargeEmaWatts += alpha * (sample.Watts - dischargeEmaWatts.Value); + } + lastTrendSampleAt = now; + } + + private double? GetAverage10m() => batterySamples.Count == 0 + ? null + : batterySamples.Average(item => item.Watts); + + private static PowerSource ReadPowerSource() + { + var status = System.Windows.Forms.SystemInformation.PowerStatus; + return status.PowerLineStatus switch + { + System.Windows.Forms.PowerLineStatus.Online => PowerSource.Ac, + System.Windows.Forms.PowerLineStatus.Offline => PowerSource.Battery, + _ => PowerSource.Unknown + }; + } + + private static long ToInt64(FileTime value) => + ((long)value.High << 32) | value.Low; + + private const uint MonitorDefaultToNearest = 2; + + private readonly record struct BatteryTrendSample(DateTimeOffset Timestamp, double Watts); + + private readonly record struct BatterySample( + PowerSource Source, + double? DischargeWatts, + double? ChargeWatts, + double? RemainingMwh, + double? VoltageMv, + double? EstimatedHours, + string Confidence, + double? DischargeEmaWatts, + double? DischargeAverage10mWatts) + { + public static BatterySample Unavailable(PowerSource source) => + new(source, null, null, null, null, null, "Unavailable", null, null); + } + + private sealed class ForegroundWindowSample + { + public bool IsFullscreen { get; set; } + public bool IsMinimized { get; set; } + public bool IsCloaked { get; set; } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + public uint Low; + public int High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MonitorInfo + { + public int CbSize; + public Rect Monitor; + public Rect Work; + public uint Flags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemBatteryState + { + public byte AcOnLine; + public byte BatteryPresent; + public byte Charging; + public byte Discharging; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)] public byte[] Spare; + public uint MaxCapacity; + public uint RemainingCapacity; + public int Rate; + public uint EstimatedTime; + public uint DefaultAlert1; + public uint DefaultAlert2; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user); + + [DllImport("powrprof.dll")] + private static extern uint CallNtPowerInformation( + int informationLevel, + IntPtr inputBuffer, + uint inputBufferLength, + IntPtr outputBuffer, + uint outputBufferLength); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr window, out Rect rect); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); + + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr window); + + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmGetWindowAttribute(IntPtr window, int attribute, out int value, int valueSize); +} + +internal static class BatteryClassTelemetry +{ + private static readonly Guid BatteryInterfaceGuid = new("72631e54-78A4-11d0-bcf7-00aa00b7b32a"); + private const uint DigcfPresent = 0x00000002; + private const uint DigcfDeviceInterface = 0x00000010; + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint IoctlBatteryQueryTag = 0x00294040; + private const uint IoctlBatteryQueryStatus = 0x0029404c; + private const int ErrorNoMoreItems = 259; + + [StructLayout(LayoutKind.Sequential)] + private struct DeviceInterfaceData + { + public int Size; + public Guid InterfaceClassGuid; + public int Flags; + public IntPtr Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BatteryWaitStatus + { + public uint BatteryTag; + public uint Timeout; + public uint PowerState; + public uint LowCapacity; + public uint HighCapacity; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BatteryStatus + { + public uint PowerState; + public uint Capacity; + public uint Voltage; + public int Rate; + } + + internal sealed record BatterySample( + bool Available, + double DischargeWatts, + double ChargeWatts, + uint RemainingCapacityMwh, + uint VoltageMv, + string Error) + { + public static BatterySample Unavailable(string error = "Battery Class API unavailable.") => + new(false, 0, 0, 0, 0, error); + } + + internal static BatterySample Read() + { + var result = BatterySample.Unavailable(string.Empty); + var batteryGuid = BatteryInterfaceGuid; + var set = SetupDiGetClassDevs( + ref batteryGuid, + IntPtr.Zero, + IntPtr.Zero, + DigcfPresent | DigcfDeviceInterface); + if (set == new IntPtr(-1)) return result with { Error = $"SetupDiGetClassDevs failed: {Marshal.GetLastWin32Error()}" }; + + try + { + long signedRateMilliwatts = 0; + ulong capacity = 0; + ulong voltage = 0; + uint index = 0; + while (true) + { + var interfaceData = new DeviceInterfaceData + { + Size = Marshal.SizeOf() + }; + if (!SetupDiEnumDeviceInterfaces( + set, + IntPtr.Zero, + ref batteryGuid, + index++, + ref interfaceData)) + { + var error = Marshal.GetLastWin32Error(); + if (error != ErrorNoMoreItems && string.IsNullOrEmpty(result.Error)) + result = result with { Error = $"SetupDiEnumDeviceInterfaces failed: {error}" }; + break; + } + + var path = GetDevicePath(set, ref interfaceData); + if (string.IsNullOrWhiteSpace(path)) continue; + using var handle = CreateFile( + path, + GenericRead | GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (handle.IsInvalid) continue; + + uint wait = 0; + if (!DeviceIoControl(handle, IoctlBatteryQueryTag, ref wait, sizeof(uint), out var tag, sizeof(uint), out _, IntPtr.Zero) + || tag == 0) continue; + var query = new BatteryWaitStatus { BatteryTag = tag }; + if (!DeviceIoControl( + handle, + IoctlBatteryQueryStatus, + ref query, + (uint)Marshal.SizeOf(), + out BatteryStatus status, + (uint)Marshal.SizeOf(), + out _, + IntPtr.Zero)) continue; + + result = result with { Available = true }; + if (status.Rate != int.MinValue) signedRateMilliwatts += status.Rate; + if (status.Capacity != uint.MaxValue) capacity += status.Capacity; + if (status.Voltage != uint.MaxValue) voltage += status.Voltage; + } + + if (!result.Available) return result with { Error = string.IsNullOrWhiteSpace(result.Error) ? "No battery interface responded." : result.Error }; + var signedWatts = signedRateMilliwatts / 1000d; + return result with + { + DischargeWatts = Math.Round(Math.Max(0, -signedWatts), 2), + ChargeWatts = Math.Round(Math.Max(0, signedWatts), 2), + RemainingCapacityMwh = capacity > uint.MaxValue ? uint.MaxValue : (uint)capacity, + VoltageMv = voltage > uint.MaxValue ? uint.MaxValue : (uint)voltage + }; + } + catch (Exception ex) + { + return result with { Error = ex.Message }; + } + finally { SetupDiDestroyDeviceInfoList(set); } + } + + private static string GetDevicePath(IntPtr set, ref DeviceInterfaceData interfaceData) + { + SetupDiGetDeviceInterfaceDetail(set, ref interfaceData, IntPtr.Zero, 0, out var required, IntPtr.Zero); + if (required == 0) return string.Empty; + var buffer = Marshal.AllocHGlobal((int)required); + try + { + Marshal.WriteInt32(buffer, IntPtr.Size == 8 ? 8 : 4 + Marshal.SystemDefaultCharSize); + if (!SetupDiGetDeviceInterfaceDetail(set, ref interfaceData, buffer, required, out _, IntPtr.Zero)) + return string.Empty; + return Marshal.PtrToStringUni(IntPtr.Add(buffer, 4)) ?? string.Empty; + } + finally { Marshal.FreeHGlobal(buffer); } + } + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern IntPtr SetupDiGetClassDevs( + ref Guid classGuid, + IntPtr enumerator, + IntPtr parentWindow, + uint flags); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern bool SetupDiEnumDeviceInterfaces( + IntPtr deviceInfoSet, + IntPtr deviceInfoData, + ref Guid interfaceClassGuid, + uint memberIndex, + ref DeviceInterfaceData interfaceData); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern bool SetupDiGetDeviceInterfaceDetail( + IntPtr deviceInfoSet, + ref DeviceInterfaceData interfaceData, + IntPtr detailData, + uint detailDataSize, + out uint requiredSize, + IntPtr deviceInfoData); + + [DllImport("setupapi.dll")] + private static extern bool SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle device, + uint controlCode, + ref uint inputBuffer, + uint inputSize, + out uint outputBuffer, + uint outputSize, + out uint bytesReturned, + IntPtr overlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle device, + uint controlCode, + ref BatteryWaitStatus inputBuffer, + uint inputSize, + out BatteryStatus outputBuffer, + uint outputSize, + out uint bytesReturned, + IntPtr overlapped); +} diff --git a/src/OpenSynapse.Agent/WakeDeviceManager.cs b/src/OpenSynapse.Agent/WakeDeviceManager.cs new file mode 100644 index 0000000..ce9a5d6 --- /dev/null +++ b/src/OpenSynapse.Agent/WakeDeviceManager.cs @@ -0,0 +1,77 @@ +using OpenSynapse.Core; + +namespace OpenSynapse.Agent; + +internal sealed class WakeDeviceManager +{ + private readonly Func, string> run; + + public WakeDeviceManager() + { + var powerCfg = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + run = arguments => ProcessRunner.Run(powerCfg, arguments.ToArray()); + } + + internal WakeDeviceManager(Func, string> run) + { + this.run = run; + } + + public IReadOnlyList GetWakeArmedDevices() => ParseDeviceNames(Run("/devicequery", "wake_armed")); + + public void Apply( + OperatingMode mode, + OpenSynapseConfig config, + OpenSynapseState state, + Action persist) + { + if (mode != OperatingMode.Quiet || !config.ManageWakeDevices) + { + Restore(state, persist); + return; + } + + var configured = new HashSet(config.QuietWakeDeviceNames, StringComparer.OrdinalIgnoreCase); + foreach (var device in state.DisabledWakeDevices.Where(device => !configured.Contains(device)).ToArray()) + RestoreDevice(device, state, persist); + + foreach (var device in GetWakeArmedDevices().Where(configured.Contains)) + { + if (!state.DisabledWakeDevices.Contains(device, StringComparer.OrdinalIgnoreCase)) + { + state.DisabledWakeDevices.Add(device); + persist(); + } + + Run("/devicedisablewake", device); + if (GetWakeArmedDevices().Contains(device, StringComparer.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Wake permission for {device} was not disabled."); + } + } + + public void Restore(OpenSynapseState state, Action persist) + { + foreach (var device in state.DisabledWakeDevices.ToArray()) + RestoreDevice(device, state, persist); + } + + private string Run(params string[] arguments) => run(arguments); + + private void RestoreDevice(string device, OpenSynapseState state, Action persist) + { + Run("/deviceenablewake", device); + if (!GetWakeArmedDevices().Contains(device, StringComparer.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Wake permission for {device} was not restored."); + state.DisabledWakeDevices.Remove(device); + persist(); + } + + private static IReadOnlyList ParseDeviceNames(string output) => output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 + && !line.Equals("NONE", StringComparison.OrdinalIgnoreCase) + && !line.Equals("无", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/src/OpenSynapse.Agent/Windows/DisplayNative.cs b/src/OpenSynapse.Agent/Windows/DisplayNative.cs index f2df5c5..859f6b1 100644 --- a/src/OpenSynapse.Agent/Windows/DisplayNative.cs +++ b/src/OpenSynapse.Agent/Windows/DisplayNative.cs @@ -452,7 +452,7 @@ public static DisplayModeInfo[] GetActiveDisplays() return result.ToArray(); } - private static int ApplyFrequency(DISPLAY_DEVICE device, bool maximum, int quietTarget) + private static int ApplyFrequency(DISPLAY_DEVICE device, bool maximum, int quietTarget, bool requireExact = false) { DEVMODE current = NewMode(); if (!EnumDisplaySettingsEx(device.DeviceName, ENUM_CURRENT_SETTINGS, ref current, 0)) @@ -482,6 +482,21 @@ private static int ApplyFrequency(DISPLAY_DEVICE device, bool maximum, int quiet if (candidate.dmDisplayFrequency > selected.dmDisplayFrequency) selected = candidate; } + else if (requireExact) + { + bool foundExact = false; + foreach (DEVMODE candidate in candidates) + { + if (Math.Abs(candidate.dmDisplayFrequency - quietTarget) <= 1) + { + selected = candidate; + foundExact = true; + break; + } + } + if (!foundExact) + throw new InvalidOperationException(device.DeviceName + " does not expose " + quietTarget + " Hz at the current resolution and color depth."); + } else { bool foundAtOrBelow = false; @@ -518,18 +533,47 @@ public static int ApplyMaximumRefresh() return changed; } - public static int ApplyQuietRefresh(int targetHz) + public static int ApplyFixedRefresh(int targetHz) { + List devices = GetActiveDevices(); + foreach (DISPLAY_DEVICE device in devices) + { + DEVMODE current = NewMode(); + if (!EnumDisplaySettingsEx(device.DeviceName, ENUM_CURRENT_SETTINGS, ref current, 0)) + continue; + bool supported = false; + for (int index = 0; ; index++) + { + DEVMODE candidate = NewMode(); + if (!EnumDisplaySettingsEx(device.DeviceName, index, ref candidate, 0)) + break; + if (candidate.dmPelsWidth == current.dmPelsWidth && + candidate.dmPelsHeight == current.dmPelsHeight && + candidate.dmBitsPerPel == current.dmBitsPerPel && + Math.Abs(candidate.dmDisplayFrequency - targetHz) <= 1) + { + supported = true; + break; + } + } + if (!supported) + throw new InvalidOperationException(device.DeviceName + " does not expose " + targetHz + " Hz at the current resolution and color depth."); + } + int changed = 0; - foreach (DISPLAY_DEVICE device in GetActiveDevices()) - changed += ApplyFrequency(device, false, targetHz); + foreach (DISPLAY_DEVICE device in devices) + changed += ApplyFrequency(device, false, targetHz, true); return changed; } public static void RestoreRegistryModes() { foreach (DISPLAY_DEVICE device in GetActiveDevices()) - ChangeDisplaySettingsExReset(device.DeviceName, IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero); + { + int change = ChangeDisplaySettingsExReset(device.DeviceName, IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero); + if (change != DISP_CHANGE_SUCCESSFUL) + throw new Win32Exception(change, "Cannot restore the registry display mode for " + device.DeviceName + "."); + } } } diff --git a/src/OpenSynapse.Agent/Windows/DynamicRefreshNative.cs b/src/OpenSynapse.Agent/Windows/DynamicRefreshNative.cs new file mode 100644 index 0000000..2fa14c3 --- /dev/null +++ b/src/OpenSynapse.Agent/Windows/DynamicRefreshNative.cs @@ -0,0 +1,251 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace PowerPilotNative; + +public sealed class DynamicRefreshInfo +{ + public bool InternalDisplayActive { get; internal set; } + public bool Supported { get; internal set; } + public bool Enabled { get; internal set; } + public int BaseFrequency { get; internal set; } + public int BoostFrequency { get; internal set; } + public string Message { get; internal set; } = string.Empty; +} + +public static class DynamicRefreshManager +{ + private const uint QueryActivePaths = 0x00000002; + private const uint QueryVirtualModeAware = 0x00000010; + private const uint QueryVirtualRefreshRateAware = 0x00000040; + private const uint SetUseSuppliedDisplayConfig = 0x00000020; + private const uint SetValidate = 0x00000040; + private const uint SetApply = 0x00000080; + private const uint SetSaveToDatabase = 0x00000200; + private const uint SetAllowChanges = 0x00000400; + private const uint SetVirtualModeAware = 0x00008000; + private const uint SetVirtualRefreshRateAware = 0x00020000; + private const uint SupportsVirtualMode = 0x00000008; + private const uint BoostRefreshRate = 0x00000010; + private const uint InternalOutput = 0x80000000; + private const uint InvalidModeIndex = 0x0000ffff; + private const int Success = 0; + private const int InsufficientBuffer = 122; + + [StructLayout(LayoutKind.Sequential)] + private struct Luid { public uint Low; public int High; } + + [StructLayout(LayoutKind.Sequential)] + private struct Rational { public uint Numerator; public uint Denominator; } + + [StructLayout(LayoutKind.Sequential)] + private struct PathSource { public Luid Adapter; public uint Id; public uint ModeIndex; public uint StatusFlags; } + + [StructLayout(LayoutKind.Sequential)] + private struct PathTarget + { + public Luid Adapter; + public uint Id; + public uint ModeIndex; + public uint OutputTechnology; + public uint Rotation; + public uint Scaling; + public Rational RefreshRate; + public uint ScanLineOrdering; + [MarshalAs(UnmanagedType.Bool)] public bool TargetAvailable; + public uint StatusFlags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PathInfo { public PathSource Source; public PathTarget Target; public uint Flags; } + + [StructLayout(LayoutKind.Sequential)] + private struct Point { public int X; public int Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct Rect { public int Left; public int Top; public int Right; public int Bottom; } + + [StructLayout(LayoutKind.Sequential)] + private struct Region { public uint Width; public uint Height; } + + [StructLayout(LayoutKind.Sequential)] + private struct Signal + { + public ulong PixelRate; + public Rational HorizontalSync; + public Rational VerticalSync; + public Region ActiveSize; + public Region TotalSize; + public uint VideoStandard; + public uint ScanLineOrdering; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TargetMode { public Signal Signal; } + + [StructLayout(LayoutKind.Sequential)] + private struct SourceMode { public uint Width; public uint Height; public uint PixelFormat; public Point Position; } + + [StructLayout(LayoutKind.Sequential)] + private struct DesktopImage { public Point Size; public Rect Region; public Rect Clip; } + + [StructLayout(LayoutKind.Explicit)] + private struct ModeUnion + { + [FieldOffset(0)] public TargetMode Target; + [FieldOffset(0)] public SourceMode Source; + [FieldOffset(0)] public DesktopImage Image; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ModeInfo { public uint Type; public uint Id; public Luid Adapter; public ModeUnion Data; } + + [DllImport("user32.dll")] + private static extern int GetDisplayConfigBufferSizes(uint flags, out uint paths, out uint modes); + + [DllImport("user32.dll")] + private static extern int QueryDisplayConfig( + uint flags, + ref uint paths, + [Out] PathInfo[] pathArray, + ref uint modes, + [Out] ModeInfo[] modeArray, + IntPtr topologyId); + + [DllImport("user32.dll")] + private static extern int SetDisplayConfig( + uint paths, + [In] PathInfo[] pathArray, + uint modes, + [In] ModeInfo[] modeArray, + uint flags); + + private static void Query(out PathInfo[] paths, out ModeInfo[] modes) + { + var flags = QueryActivePaths | QueryVirtualModeAware | QueryVirtualRefreshRateAware; + for (var attempt = 0; attempt < 5; attempt++) + { + var result = GetDisplayConfigBufferSizes(flags, out var pathCount, out var modeCount); + if (result != Success) throw new Win32Exception(result, "Cannot size display paths for dynamic refresh."); + var pathBuffer = new PathInfo[pathCount]; + var modeBuffer = new ModeInfo[modeCount]; + result = QueryDisplayConfig(flags, ref pathCount, pathBuffer, ref modeCount, modeBuffer, IntPtr.Zero); + if (result == InsufficientBuffer) continue; + if (result != Success) throw new Win32Exception(result, "Cannot query display paths for dynamic refresh."); + paths = pathBuffer[..(int)pathCount]; + modes = modeBuffer[..(int)modeCount]; + return; + } + throw new Win32Exception(InsufficientBuffer, "Display paths kept changing."); + } + + private static bool IsInternal(PathInfo path) => path.Target.OutputTechnology == InternalOutput; + + private static int RationalHz(Rational value) => value.Denominator == 0 + ? 0 + : (int)Math.Round((double)value.Numerator / value.Denominator); + + private static int TargetModeHz(PathInfo path, ModeInfo[] modes) + { + var index = (path.Flags & SupportsVirtualMode) != 0 + ? (path.Target.ModeIndex >> 16) & 0xffffu + : path.Target.ModeIndex; + return index == InvalidModeIndex || index >= modes.Length || modes[index].Type != 2 + ? 0 + : RationalHz(modes[index].Data.Target.Signal.VerticalSync); + } + + public static DynamicRefreshInfo GetStatus() + { + Query(out var paths, out var modes); + var info = new DynamicRefreshInfo { Message = "No active internal display path." }; + foreach (var path in paths) + { + if (!IsInternal(path)) continue; + info.InternalDisplayActive = true; + info.Supported = (path.Flags & SupportsVirtualMode) != 0; + info.Enabled = (path.Flags & BoostRefreshRate) != 0; + info.BaseFrequency = RationalHz(path.Target.RefreshRate); + info.BoostFrequency = TargetModeHz(path, modes); + info.Message = info.Supported + ? "Windows dynamic refresh is available." + : "The active internal path does not advertise virtual refresh support."; + return info; + } + return info; + } + + public static bool ValidateNativeDynamic() + { + Query(out var paths, out var modes); + var found = false; + for (var index = 0; index < paths.Length; index++) + { + if (!IsInternal(paths[index])) continue; + if ((paths[index].Flags & SupportsVirtualMode) == 0) return false; + var path = paths[index]; + path.Flags |= BoostRefreshRate; + path.Target.RefreshRate = new Rational { Numerator = 60, Denominator = 1 }; + paths[index] = path; + found = true; + } + if (!found) return false; + var flags = SetValidate | SetUseSuppliedDisplayConfig | SetAllowChanges + | SetVirtualModeAware | SetVirtualRefreshRateAware; + return SetDisplayConfig((uint)paths.Length, paths, (uint)modes.Length, modes, flags) == Success; + } + + public static int EnableNativeDynamic() + { + var before = GetStatus(); + if (!before.InternalDisplayActive) + throw new InvalidOperationException("The internal display is not active; dynamic refresh was not applied."); + if (!before.Supported) + throw new InvalidOperationException("The internal display path does not support Windows dynamic refresh."); + + var changed = DisplayModeManager.ApplyFixedRefresh(120); + Query(out var paths, out var modes); + for (var index = 0; index < paths.Length; index++) + { + if (!IsInternal(paths[index])) continue; + var path = paths[index]; + path.Flags |= BoostRefreshRate; + path.Target.RefreshRate = new Rational { Numerator = 60, Denominator = 1 }; + paths[index] = path; + } + var flags = SetApply | SetUseSuppliedDisplayConfig | SetAllowChanges | SetSaveToDatabase + | SetVirtualModeAware | SetVirtualRefreshRateAware; + var result = SetDisplayConfig((uint)paths.Length, paths, (uint)modes.Length, modes, flags); + if (result != Success) throw new Win32Exception(result, "Cannot enable Windows dynamic refresh."); + + var after = GetStatus(); + if (!after.Enabled || Math.Abs(after.BaseFrequency - 60) > 1 || after.BoostFrequency <= 60) + { + try { Disable(); } catch { } + throw new InvalidOperationException("Windows accepted the request but did not report a valid native dynamic refresh range."); + } + return changed + 1; + } + + public static int Disable() + { + Query(out var paths, out var modes); + var changed = false; + for (var index = 0; index < paths.Length; index++) + { + if (!IsInternal(paths[index]) || (paths[index].Flags & BoostRefreshRate) == 0) continue; + var path = paths[index]; + path.Flags &= ~BoostRefreshRate; + var physicalHz = TargetModeHz(path, modes); + if (physicalHz > 0) path.Target.RefreshRate = new Rational { Numerator = (uint)physicalHz, Denominator = 1 }; + paths[index] = path; + changed = true; + } + if (!changed) return 0; + var flags = SetApply | SetUseSuppliedDisplayConfig | SetAllowChanges | SetSaveToDatabase + | SetVirtualModeAware | SetVirtualRefreshRateAware; + var result = SetDisplayConfig((uint)paths.Length, paths, (uint)modes.Length, modes, flags); + if (result != Success) throw new Win32Exception(result, "Cannot disable Windows dynamic refresh."); + return 1; + } +} diff --git a/src/OpenSynapse.Agent/WindowsDisplaySystem.cs b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs new file mode 100644 index 0000000..f124cb8 --- /dev/null +++ b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs @@ -0,0 +1,69 @@ +using PowerPilotNative; + +namespace OpenSynapse.Agent; + +internal sealed record AdvancedColorSnapshot(string Key, bool Supported, bool Enabled); +internal sealed record ActiveDisplaySnapshot(string Key, int CurrentScalePercent, bool IsInternal); + +internal interface IDisplaySystem +{ + IReadOnlyList GetAdvancedColors(); + void SetAdvancedColor(string key, bool enabled); + int? GetBrightness(); + void SetBrightness(int percent); + IReadOnlyList GetDisplays(); + void SetDisplayScale(string key, int desiredPercent); + void ApplyMaximumRefresh(); + void ApplyFixedRefresh(int targetHz); + void ApplyDynamicNativeRefresh(); + void RestoreRefresh(); +} + +internal sealed class WindowsDisplaySystem : IDisplaySystem +{ + public IReadOnlyList GetAdvancedColors() => AdvancedColorManager.GetStatus() + .Select(item => new AdvancedColorSnapshot(item.Key, item.Supported, item.Enabled)) + .ToArray(); + + public void SetAdvancedColor(string key, bool enabled) => + _ = AdvancedColorManager.SetEnabled(key, enabled); + + public int? GetBrightness() + { + var output = RunPowerShell( + "(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightness | Where-Object Active | Select-Object -First 1).CurrentBrightness"); + return int.TryParse(output, out var brightness) ? brightness : null; + } + + public void SetBrightness(int percent) => RunPowerShell( + "$methods = @(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods | Where-Object Active); if (-not $methods) { throw 'No active brightness controller.' }; $methods | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName WmiSetBrightness -Arguments @{Timeout=1;Brightness=[byte]" + + percent + + "} | Out-Null }"); + + public IReadOnlyList GetDisplays() => DisplayScaling.GetActiveDisplays() + .Select(display => new ActiveDisplaySnapshot(display.Key, display.CurrentPercent, display.IsInternal)) + .ToArray(); + + public void SetDisplayScale(string key, int desiredPercent) + { + var display = DisplayScaling.GetActiveDisplays() + .SingleOrDefault(item => string.Equals(item.Key, key, StringComparison.Ordinal)); + if (display is null) throw new InvalidOperationException($"Display {key} is no longer active."); + _ = DisplayScaling.SetScale(display, desiredPercent); + } + + public void ApplyMaximumRefresh() => _ = DisplayModeManager.ApplyMaximumRefresh(); + + public void ApplyFixedRefresh(int targetHz) => _ = DisplayModeManager.ApplyFixedRefresh(targetHz); + + public void ApplyDynamicNativeRefresh() => _ = DynamicRefreshManager.EnableNativeDynamic(); + + public void RestoreRefresh() => DisplayModeManager.RestoreRegistryModes(); + + private static string RunPowerShell(string command) => ProcessRunner.Run( + Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + "-NoProfile", + "-NonInteractive", + "-Command", + command); +} diff --git a/src/OpenSynapse.App/AgentClient.cs b/src/OpenSynapse.App/AgentClient.cs index 5476f7b..732acba 100644 --- a/src/OpenSynapse.App/AgentClient.cs +++ b/src/OpenSynapse.App/AgentClient.cs @@ -19,7 +19,9 @@ public async Task SendAsync(AgentRequest request, CancellationTok ".", "OpenSynapse.Agent", PipeDirection.InOut, - PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + // The server applies an explicit same-user ACL and low-integrity + // label so an unelevated UI can talk to the elevated Agent. + PipeOptions.Asynchronous); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(3)); await pipe.ConnectAsync(timeout.Token); diff --git a/src/OpenSynapse.App/App.xaml b/src/OpenSynapse.App/App.xaml index 85d42cf..f656c5c 100644 --- a/src/OpenSynapse.App/App.xaml +++ b/src/OpenSynapse.App/App.xaml @@ -1,7 +1,6 @@ diff --git a/src/OpenSynapse.App/App.xaml.cs b/src/OpenSynapse.App/App.xaml.cs index 61d6c05..b2d3041 100644 --- a/src/OpenSynapse.App/App.xaml.cs +++ b/src/OpenSynapse.App/App.xaml.cs @@ -1,5 +1,61 @@ +using System.Security.Principal; +using System.Runtime.InteropServices; +using OpenSynapse.Core; + namespace OpenSynapse.App; public partial class App : System.Windows.Application { + private SingleInstanceLease? instanceLease; + private EventWaitHandle? activationEvent; + private CancellationTokenSource? activationCancellation; + private Task? activationWatcher; + + protected override void OnStartup(System.Windows.StartupEventArgs e) + { + _ = SetCurrentProcessExplicitAppUserModelID("OpenSynapse.Desktop"); + base.OnStartup(e); + var user = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName; + var suffix = user.Replace('\\', '_'); + activationEvent = new EventWaitHandle( + false, + EventResetMode.AutoReset, + $"Local\\OpenSynapse.App.Activate.{suffix}"); + instanceLease = SingleInstanceLease.TryAcquire($"Local\\OpenSynapse.App.{suffix}"); + if (instanceLease is null) + { + activationEvent.Set(); + activationEvent.Dispose(); + activationEvent = null; + Shutdown(); + return; + } + + activationCancellation = new CancellationTokenSource(); + activationWatcher = Task.Run(() => WatchForActivation(activationCancellation.Token)); + var window = new MainWindow(); + MainWindow = window; + window.Show(); + } + + protected override void OnExit(System.Windows.ExitEventArgs e) + { + activationCancellation?.Cancel(); + activationEvent?.Set(); + try { activationWatcher?.Wait(TimeSpan.FromSeconds(1)); } catch (AggregateException) { } + activationCancellation?.Dispose(); + activationEvent?.Dispose(); + instanceLease?.Dispose(); + base.OnExit(e); + } + + private void WatchForActivation(CancellationToken cancellationToken) + { + var handles = new[] { activationEvent!, cancellationToken.WaitHandle }; + while (WaitHandle.WaitAny(handles) == 0 && !cancellationToken.IsCancellationRequested) + Dispatcher.BeginInvoke(() => (MainWindow as MainWindow)?.ActivateFromExternalRequest()); + } + + [DllImport("shell32.dll")] + private static extern int SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string appId); } diff --git a/src/OpenSynapse.App/Assets/OpenSynapse.App.ico b/src/OpenSynapse.App/Assets/OpenSynapse.App.ico new file mode 100644 index 0000000..eb910de Binary files /dev/null and b/src/OpenSynapse.App/Assets/OpenSynapse.App.ico differ diff --git a/src/OpenSynapse.App/Assets/OpenSynapse.Tray.ico b/src/OpenSynapse.App/Assets/OpenSynapse.Tray.ico new file mode 100644 index 0000000..05f8fb3 Binary files /dev/null and b/src/OpenSynapse.App/Assets/OpenSynapse.Tray.ico differ diff --git a/src/OpenSynapse.App/MainWindow.xaml b/src/OpenSynapse.App/MainWindow.xaml index 71314dc..77e4f0a 100644 --- a/src/OpenSynapse.App/MainWindow.xaml +++ b/src/OpenSynapse.App/MainWindow.xaml @@ -1,69 +1,371 @@ + Title="OpenSynapse" Height="900" Width="1440" MinHeight="760" MinWidth="1180" + WindowStyle="None" ResizeMode="CanResize" + Background="#090B0A" Foreground="#F4F4F4" Loaded="Window_Loaded" Closing="Window_Closing"> + + + + - - - - - - - - - - - + + + + + + + + + +