From 07ab28e4fdeadfe59e1dde90657eec41d543c808 Mon Sep 17 00:00:00 2001 From: MoveLikeYager Date: Mon, 20 Jul 2026 17:09:54 +0800 Subject: [PATCH 01/17] fix: make display rollback durable --- OpenSynapse.sln | 15 ++ src/OpenSynapse.Agent/AgentController.cs | 6 +- src/OpenSynapse.Agent/DisplayPolicy.cs | 129 ++++++++----- src/OpenSynapse.Agent/OpenSynapseState.cs | 31 +++- .../Properties/AssemblyInfo.cs | 3 + .../Windows/DisplayNative.cs | 6 +- src/OpenSynapse.Agent/WindowsDisplaySystem.cs | 66 +++++++ .../DisplayPolicyTests.cs | 169 ++++++++++++++++++ .../OpenSynapse.Agent.Tests/MSTestSettings.cs | 1 + .../OpenSynapse.Agent.Tests.csproj | 23 +++ .../StateStoreTests.cs | 61 +++++++ 11 files changed, 459 insertions(+), 51 deletions(-) create mode 100644 src/OpenSynapse.Agent/Properties/AssemblyInfo.cs create mode 100644 src/OpenSynapse.Agent/WindowsDisplaySystem.cs create mode 100644 tests/OpenSynapse.Agent.Tests/DisplayPolicyTests.cs create mode 100644 tests/OpenSynapse.Agent.Tests/MSTestSettings.cs create mode 100644 tests/OpenSynapse.Agent.Tests/OpenSynapse.Agent.Tests.csproj create mode 100644 tests/OpenSynapse.Agent.Tests/StateStoreTests.cs 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/src/OpenSynapse.Agent/AgentController.cs b/src/OpenSynapse.Agent/AgentController.cs index eac9172..e4d69b2 100644 --- a/src/OpenSynapse.Agent/AgentController.cs +++ b/src/OpenSynapse.Agent/AgentController.cs @@ -61,8 +61,10 @@ private AgentResponse Handle(AgentRequest request) shuttingDown = request.Operation == AgentOperation.Shutdown; try { - displays.Restore(state); + var displayRestored = displays.Restore(state); power.Restore(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." @@ -107,7 +109,7 @@ private AgentResponse ApplyMode(OperatingMode mode, OpenSynapseState state) try { state.OriginalPowerPlan ??= power.GetActiveGuid(); - if (mode == OperatingMode.Quiet) displays.CaptureForQuiet(state); + displays.Capture(mode, state); store.Save(state); power.Apply(mode, state); displays.Apply(mode, state); diff --git a/src/OpenSynapse.Agent/DisplayPolicy.cs b/src/OpenSynapse.Agent/DisplayPolicy.cs index 0b7dd61..daa2e5c 100644 --- a/src/OpenSynapse.Agent/DisplayPolicy.cs +++ b/src/OpenSynapse.Agent/DisplayPolicy.cs @@ -1,24 +1,43 @@ using OpenSynapse.Core; -using PowerPilotNative; - namespace OpenSynapse.Agent; internal sealed class DisplayPolicy { + private readonly IDisplaySystem displaySystem; + + public DisplayPolicy() + : this(new WindowsDisplaySystem()) + { + } + + internal DisplayPolicy(IDisplaySystem displaySystem) + { + this.displaySystem = displaySystem; + } + + public void Capture(OperatingMode mode, OpenSynapseState state) + { + CaptureDisplayScales(state); + if (mode == OperatingMode.Quiet) CaptureForQuiet(state); + } + public void CaptureForQuiet(OpenSynapseState state) { if (state.AdvancedColors.Count == 0) { try { - state.AdvancedColors = AdvancedColorManager.GetStatus() + state.AdvancedColors = displaySystem.GetAdvancedColors() .Where(item => item.Supported) .Select(item => new AdvancedColorState(item.Key, item.Enabled)) .ToList(); } catch { } } - state.OriginalBrightness ??= GetBrightness(); + if (state.OriginalBrightness is null) + { + try { state.OriginalBrightness = displaySystem.GetBrightness(); } catch { } + } } public void Apply(OperatingMode mode, OpenSynapseState state) @@ -27,74 +46,98 @@ public void Apply(OperatingMode mode, OpenSynapseState state) { CaptureForQuiet(state); foreach (var color in state.AdvancedColors) - try { AdvancedColorManager.SetEnabled(color.Key, false); } catch { } - _ = SetBrightness(40); - try { DisplayModeManager.ApplyQuietRefresh(60); } catch { } + try { displaySystem.SetAdvancedColor(color.Key, false); } catch { } + try { displaySystem.SetBrightness(40); } catch { } + try { displaySystem.ApplyQuietRefresh(60); } catch { } } else { - RestoreCapturedDisplayState(state); - try { DisplayModeManager.ApplyMaximumRefresh(); } catch { } + RestoreCapturedDisplayState(state, clearCompleted: false, restoreScales: false); + try { displaySystem.ApplyMaximumRefresh(); } catch { } } try { - foreach (var display in DisplayScaling.GetActiveDisplays()) - try { DisplayScaling.SetScale(display, display.IsInternal ? 150 : 125); } catch { } + foreach (var display in displaySystem.GetDisplays()) + try { displaySystem.SetDisplayScale(display.Key, display.IsInternal ? 150 : 125); } catch { } } catch { } } - public void Restore(OpenSynapseState state) + public bool Restore(OpenSynapseState state) { - RestoreCapturedDisplayState(state); - try { DisplayModeManager.RestoreRegistryModes(); } catch { } + 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 static void RestoreCapturedDisplayState(OpenSynapseState state) + private void CaptureDisplayScales(OpenSynapseState state) { - foreach (var color in state.AdvancedColors) - try { AdvancedColorManager.SetEnabled(color.Key, color.Enabled); } catch { } + if (state.DisplayScales.Count != 0) return; try { - 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); + state.DisplayScales = displaySystem.GetDisplays() + .Select(display => new DisplayScaleState(display.Key, display.CurrentScalePercent)) + .ToList(); } catch { } - if (state.OriginalBrightness is int brightness) - { - if (SetBrightness(brightness)) state.OriginalBrightness = null; - } } - private static int? GetBrightness() + private void RestoreCapturedDisplayState(OpenSynapseState state, bool clearCompleted, bool restoreScales) { - try + foreach (var color in state.AdvancedColors) + try { displaySystem.SetAdvancedColor(color.Key, color.Enabled); } catch { } + if (clearCompleted) + { + 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 { } + } + if (state.OriginalBrightness is int brightness) { - 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; + try { displaySystem.SetBrightness(brightness); } catch { } + if (clearCompleted) + { + try + { + if (displaySystem.GetBrightness() == brightness) state.OriginalBrightness = null; + } + catch { } + } } - catch { return null; } + if (restoreScales) RestoreDisplayScales(state, clearCompleted); } - 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); } diff --git a/src/OpenSynapse.Agent/OpenSynapseState.cs b/src/OpenSynapse.Agent/OpenSynapseState.cs index 85a4aa9..80dbd01 100644 --- a/src/OpenSynapse.Agent/OpenSynapseState.cs +++ b/src/OpenSynapse.Agent/OpenSynapseState.cs @@ -5,6 +5,9 @@ namespace OpenSynapse.Agent; internal sealed class OpenSynapseState { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; public ModeSelection Selection { get; set; } = ModeSelection.Auto; public string? OriginalPowerPlan { get; set; } public string? PerformancePowerPlan { get; set; } @@ -12,24 +15,38 @@ internal sealed class OpenSynapseState public OperatingMode? ActiveMode { get; set; } public int? OriginalBrightness { get; set; } public List AdvancedColors { get; set; } = []; + public List DisplayScales { 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}."); + state.SchemaVersion = OpenSynapseState.CurrentSchemaVersion; + state.AdvancedColors ??= []; + state.DisplayScales ??= []; + return state; } catch (JsonException) { @@ -39,6 +56,10 @@ public OpenSynapseState Load() 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/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/Windows/DisplayNative.cs b/src/OpenSynapse.Agent/Windows/DisplayNative.cs index f2df5c5..35a0645 100644 --- a/src/OpenSynapse.Agent/Windows/DisplayNative.cs +++ b/src/OpenSynapse.Agent/Windows/DisplayNative.cs @@ -529,7 +529,11 @@ public static int ApplyQuietRefresh(int targetHz) 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/WindowsDisplaySystem.cs b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs new file mode 100644 index 0000000..df61488 --- /dev/null +++ b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs @@ -0,0 +1,66 @@ +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 ApplyQuietRefresh(int targetHz); + 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 ApplyQuietRefresh(int targetHz) => _ = DisplayModeManager.ApplyQuietRefresh(targetHz); + + 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/tests/OpenSynapse.Agent.Tests/DisplayPolicyTests.cs b/tests/OpenSynapse.Agent.Tests/DisplayPolicyTests.cs new file mode 100644 index 0000000..d36d74d --- /dev/null +++ b/tests/OpenSynapse.Agent.Tests/DisplayPolicyTests.cs @@ -0,0 +1,169 @@ +using OpenSynapse.Core; + +namespace OpenSynapse.Agent.Tests; + +[TestClass] +public sealed class DisplayPolicyTests +{ + [TestMethod] + public void CapturePreservesTheFirstDisplayStateUntilFinalRestore() + { + var displaySystem = new FakeDisplaySystem + { + Brightness = 82 + }; + displaySystem.Displays["internal"] = (100, true); + displaySystem.Colors["hdr"] = (true, true); + displaySystem.Colors["unsupported"] = (false, true); + var state = new OpenSynapseState(); + var policy = new DisplayPolicy(displaySystem); + + policy.Capture(OperatingMode.Quiet, state); + displaySystem.Displays["internal"] = (150, true); + policy.Capture(OperatingMode.Performance, state); + + Assert.AreEqual(82, state.OriginalBrightness); + CollectionAssert.AreEqual( + new[] { new AdvancedColorState("hdr", true) }, + state.AdvancedColors); + CollectionAssert.AreEqual( + new[] { new DisplayScaleState("internal", 100) }, + state.DisplayScales); + } + + [TestMethod] + public void PerformanceTransitionRestoresQuietChangesButRetainsRollbackState() + { + var displaySystem = new FakeDisplaySystem + { + Brightness = 40 + }; + displaySystem.Displays["internal"] = (150, true); + displaySystem.Colors["hdr"] = (true, false); + var state = new OpenSynapseState + { + OriginalBrightness = 82, + AdvancedColors = [new AdvancedColorState("hdr", true)], + DisplayScales = [new DisplayScaleState("internal", 100)] + }; + var policy = new DisplayPolicy(displaySystem); + + policy.Apply(OperatingMode.Performance, state); + + Assert.AreEqual(82, displaySystem.Brightness); + Assert.IsTrue(displaySystem.Colors["hdr"].Enabled); + Assert.AreEqual(1, displaySystem.MaximumRefreshApplications); + CollectionAssert.AreEqual( + new[] { ("internal", 150) }, + displaySystem.ScaleWrites); + Assert.HasCount(1, state.AdvancedColors); + Assert.HasCount(1, state.DisplayScales); + Assert.AreEqual(82, state.OriginalBrightness); + } + + [TestMethod] + public void RestoreClearsOnlyStateConfirmedByReadback() + { + var displaySystem = new FakeDisplaySystem + { + Brightness = 40 + }; + displaySystem.Displays["internal"] = (150, true); + displaySystem.Colors["hdr"] = (true, false); + var state = new OpenSynapseState + { + OriginalBrightness = 82, + AdvancedColors = + [ + new AdvancedColorState("hdr", true), + new AdvancedColorState("disconnected-color", true) + ], + DisplayScales = + [ + new DisplayScaleState("internal", 100), + new DisplayScaleState("disconnected-display", 125) + ] + }; + var policy = new DisplayPolicy(displaySystem); + + var firstResult = policy.Restore(state); + + Assert.IsFalse(firstResult); + Assert.IsNull(state.OriginalBrightness); + CollectionAssert.AreEqual( + new[] { new AdvancedColorState("disconnected-color", true) }, + state.AdvancedColors); + CollectionAssert.AreEqual( + new[] { new DisplayScaleState("disconnected-display", 125) }, + state.DisplayScales); + + displaySystem.Colors["disconnected-color"] = (true, false); + displaySystem.Displays["disconnected-display"] = (150, false); + + var secondResult = policy.Restore(state); + + Assert.IsTrue(secondResult); + Assert.IsEmpty(state.AdvancedColors); + Assert.IsEmpty(state.DisplayScales); + } + + [TestMethod] + public void RestoreReportsPendingWhenRefreshResetFails() + { + var displaySystem = new FakeDisplaySystem + { + ThrowOnRefreshRestore = true + }; + var policy = new DisplayPolicy(displaySystem); + + var restored = policy.Restore(new OpenSynapseState()); + + Assert.IsFalse(restored); + } + + private sealed class FakeDisplaySystem : IDisplaySystem + { + public Dictionary Colors { get; } = []; + public Dictionary Displays { get; } = []; + public List<(string Key, int Percent)> ScaleWrites { get; } = []; + public int? Brightness { get; set; } + public int MaximumRefreshApplications { get; private set; } + public bool ThrowOnRefreshRestore { get; init; } + + public IReadOnlyList GetAdvancedColors() => Colors + .Select(item => new AdvancedColorSnapshot(item.Key, item.Value.Supported, item.Value.Enabled)) + .ToArray(); + + public void SetAdvancedColor(string key, bool enabled) + { + if (!Colors.TryGetValue(key, out var color)) return; + Colors[key] = (color.Supported, enabled); + } + + public int? GetBrightness() => Brightness; + + public void SetBrightness(int percent) => Brightness = percent; + + public IReadOnlyList GetDisplays() => Displays + .Select(item => new ActiveDisplaySnapshot(item.Key, item.Value.Scale, item.Value.IsInternal)) + .ToArray(); + + public void SetDisplayScale(string key, int desiredPercent) + { + if (!Displays.TryGetValue(key, out var display)) return; + ScaleWrites.Add((key, desiredPercent)); + Displays[key] = (desiredPercent, display.IsInternal); + } + + public void ApplyMaximumRefresh() => MaximumRefreshApplications++; + + public void ApplyQuietRefresh(int targetHz) + { + } + + public void RestoreRefresh() + { + if (ThrowOnRefreshRestore) throw new InvalidOperationException("Refresh reset failed."); + } + } +} diff --git a/tests/OpenSynapse.Agent.Tests/MSTestSettings.cs b/tests/OpenSynapse.Agent.Tests/MSTestSettings.cs new file mode 100644 index 0000000..300f5b1 --- /dev/null +++ b/tests/OpenSynapse.Agent.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/tests/OpenSynapse.Agent.Tests/OpenSynapse.Agent.Tests.csproj b/tests/OpenSynapse.Agent.Tests/OpenSynapse.Agent.Tests.csproj new file mode 100644 index 0000000..fbd9332 --- /dev/null +++ b/tests/OpenSynapse.Agent.Tests/OpenSynapse.Agent.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0-windows + true + latest + enable + enable + + + + + + + + + + + + + + + diff --git a/tests/OpenSynapse.Agent.Tests/StateStoreTests.cs b/tests/OpenSynapse.Agent.Tests/StateStoreTests.cs new file mode 100644 index 0000000..b5e3cc8 --- /dev/null +++ b/tests/OpenSynapse.Agent.Tests/StateStoreTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; + +namespace OpenSynapse.Agent.Tests; + +[TestClass] +public sealed class StateStoreTests +{ + private string directory = null!; + private string statePath = null!; + + [TestInitialize] + public void Initialize() + { + directory = Path.Combine(Path.GetTempPath(), "OpenSynapse.Tests", Guid.NewGuid().ToString("N")); + statePath = Path.Combine(directory, "state.json"); + Directory.CreateDirectory(directory); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + } + + [TestMethod] + public void LoadMigratesLegacyNullCollectionsToCurrentSchema() + { + File.WriteAllText(statePath, "{\"schemaVersion\":0,\"advancedColors\":null}"); + + var state = new StateStore(statePath).Load(); + + Assert.AreEqual(OpenSynapseState.CurrentSchemaVersion, state.SchemaVersion); + Assert.IsNotNull(state.AdvancedColors); + Assert.IsNotNull(state.DisplayScales); + Assert.IsEmpty(state.AdvancedColors); + Assert.IsEmpty(state.DisplayScales); + } + + [TestMethod] + public void LoadRejectsStateFromANewerSchema() + { + File.WriteAllText( + statePath, + "{\"schemaVersion\":" + (OpenSynapseState.CurrentSchemaVersion + 1) + "}"); + + Assert.ThrowsExactly(() => new StateStore(statePath).Load()); + } + + [TestMethod] + public void SaveWritesTheCurrentSchemaVersion() + { + var state = new OpenSynapseState { SchemaVersion = 0 }; + + new StateStore(statePath).Save(state); + + using var document = JsonDocument.Parse(File.ReadAllText(statePath)); + Assert.AreEqual( + OpenSynapseState.CurrentSchemaVersion, + document.RootElement.GetProperty("schemaVersion").GetInt32()); + } +} From e9807874fd22816b93cc3a4b9bd98648f40e1e66 Mon Sep 17 00:00:00 2001 From: MoveLikeYager Date: Mon, 20 Jul 2026 17:15:32 +0800 Subject: [PATCH 02/17] feat: classify adapter power safely --- README.md | 7 +- docs/ARCHITECTURE.md | 7 +- src/OpenSynapse.Agent/AgentController.cs | 40 ++++--- src/OpenSynapse.Agent/PowerSupplyProbe.cs | 106 ++++++++++++++++++ src/OpenSynapse.App/MainWindow.xaml.cs | 14 ++- src/OpenSynapse.Core/Models.cs | 38 ++++++- .../PowerSupplyProbeTests.cs | 77 +++++++++++++ .../ModeSelectorTests.cs | 18 +-- .../SupplyClassifierTests.cs | 32 ++++++ 9 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 src/OpenSynapse.Agent/PowerSupplyProbe.cs create mode 100644 tests/OpenSynapse.Agent.Tests/PowerSupplyProbeTests.cs create mode 100644 tests/OpenSynapse.Core.Tests/SupplyClassifierTests.cs diff --git a/README.md b/README.md index b104b9c..c04ee9b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ 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 | +| Windows policies | Adapter-aware 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 | | Razer mouse | Discovery, status, DPI, and standard-receiver polling control | Experimental | @@ -63,8 +63,8 @@ Requirements: ```powershell dotnet restore OpenSynapse.sln -dotnet build OpenSynapse.sln --no-restore -dotnet test tests/OpenSynapse.Core.Tests/OpenSynapse.Core.Tests.csproj --no-build +dotnet build OpenSynapse.sln --configuration Release --no-restore +dotnet test OpenSynapse.sln --configuration Release --no-build --no-restore ``` Start the elevated agent, then launch the UI from a normal terminal: @@ -92,6 +92,7 @@ The script verifies Performance/Quiet application, named-pipe lifecycle, agent s - 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`. +- 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e1b37f..573dd08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,8 +11,9 @@ 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"] + State["%LOCALAPPDATA%\\OpenSynapse\nversioned captured state"] Windows["Windows APIs and powercfg"] + Supply["Windows power status and read-only nvidia-smi"] HID["Supported Razer HID control interface"] UI --> Core @@ -20,6 +21,7 @@ flowchart LR Agent --> Core Agent --> State Agent --> Windows + Agent --> Supply Agent --> HID ``` @@ -33,7 +35,7 @@ Runs elevated for the current user. It owns mode selection, power-source reactio ### OpenSynapse.Core -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. +Contains shared request/status models, fail-safe supply classification, deterministic mode selection, and device packet construction/validation. Logic that can be independent of Windows or hardware belongs here and leaves a runnable test. ## State transition @@ -57,6 +59,7 @@ The Captured State is not deleted merely because a restore was attempted. Each v - 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. +- Automatic Performance requires a high-power AC classification. Adapter probing is read-only, cached, and falls back to Quiet when unavailable or ambiguous. - 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. diff --git a/src/OpenSynapse.Agent/AgentController.cs b/src/OpenSynapse.Agent/AgentController.cs index e4d69b2..83f60a1 100644 --- a/src/OpenSynapse.Agent/AgentController.cs +++ b/src/OpenSynapse.Agent/AgentController.cs @@ -8,6 +8,7 @@ internal sealed class AgentController private readonly object gate = new(); private readonly StateStore store = new(); private readonly PowerPlanManager power = new(); + private readonly PowerSupplyProbe powerSupply = new(); private readonly DisplayPolicy displays = new(); private readonly DeathAdderHid deathAdder = new(); private bool shuttingDown; @@ -29,7 +30,8 @@ public void ApplyCurrentSelection() try { var state = store.Load(); - ApplyMode(ModeSelector.Resolve(state.Selection, GetPowerSource()), state); + var powerSnapshot = powerSupply.GetSnapshot(); + ApplyMode(ModeSelector.Resolve(state.Selection, powerSnapshot), state, powerSnapshot); } catch (Exception ex) { @@ -53,7 +55,8 @@ private AgentResponse Handle(AgentRequest request) case AgentOperation.SetSelection: state.Selection = request.Selection ?? throw new ArgumentException("Selection is required."); store.Save(state); - return ApplyMode(ModeSelector.Resolve(state.Selection, GetPowerSource()), state); + var powerSnapshot = powerSupply.GetSnapshot(); + return ApplyMode(ModeSelector.Resolve(state.Selection, powerSnapshot), state, powerSnapshot); case AgentOperation.Restore: case AgentOperation.Shutdown: @@ -96,14 +99,24 @@ private AgentResponse Handle(AgentRequest request) } } - private AgentStatus GetStatus(OpenSynapseState state) => new( - power.GetActiveGuid(), - state.ActiveMode, - state.Selection, - GetPowerSource(), - deathAdder.ReadDevices()); + private AgentStatus GetStatus(OpenSynapseState state, PowerSnapshot? powerSnapshot = null) + { + powerSnapshot ??= powerSupply.GetSnapshot(); + return new AgentStatus( + power.GetActiveGuid(), + state.ActiveMode, + state.Selection, + powerSnapshot.Source, + powerSnapshot.SupplyType, + powerSnapshot.BatteryPercent, + powerSnapshot.AdapterLimitWatts, + deathAdder.ReadDevices()); + } - private AgentResponse ApplyMode(OperatingMode mode, OpenSynapseState state) + private AgentResponse ApplyMode( + OperatingMode mode, + OpenSynapseState state, + PowerSnapshot? powerSnapshot = null) { RequireAdministrator(); try @@ -114,18 +127,11 @@ private AgentResponse ApplyMode(OperatingMode mode, OpenSynapseState state) power.Apply(mode, state); displays.Apply(mode, state); state.ActiveMode = mode; - return new AgentResponse(true, $"Applied {mode} mode.", GetStatus(state)); + return new AgentResponse(true, $"Applied {mode} mode.", GetStatus(state, powerSnapshot)); } finally { store.Save(state); } } - private static PowerSource GetPowerSource() => System.Windows.Forms.SystemInformation.PowerStatus.PowerLineStatus switch - { - System.Windows.Forms.PowerLineStatus.Online => PowerSource.Ac, - System.Windows.Forms.PowerLineStatus.Offline => PowerSource.Battery, - _ => PowerSource.Unknown - }; - private static void RequireAdministrator() { using var identity = WindowsIdentity.GetCurrent(); 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.App/MainWindow.xaml.cs b/src/OpenSynapse.App/MainWindow.xaml.cs index 054e39d..eee67ee 100644 --- a/src/OpenSynapse.App/MainWindow.xaml.cs +++ b/src/OpenSynapse.App/MainWindow.xaml.cs @@ -97,7 +97,10 @@ private async Task SendAsync(AgentRequest request) private void UpdateStatus(AgentStatus status) { selection = status.Selection; - ModeText.Text = $"Selection: {status.Selection} Power: {status.PowerSource} Active: {status.ActiveMode?.ToString() ?? "unmanaged"}"; + var battery = status.BatteryPercent is int percent ? $" / {percent}%" : string.Empty; + var adapterLimit = status.AdapterLimitWatts is double watts ? $" / {watts:0.#} W GPU limit" : string.Empty; + PowerText.Text = $"Power: {GetSupplyDisplayName(status.SupplyType)}{battery}{adapterLimit}"; + ModeText.Text = $"Selection: {status.Selection} Active: {status.ActiveMode?.ToString() ?? "unmanaged"}"; var mouse = status.RazerDevices.FirstOrDefault(); MouseText.Text = mouse is null ? "No supported mouse detected. Supported PIDs: 00B6, 00B7, 00C2, 00C3." @@ -108,6 +111,15 @@ private void UpdateStatus(AgentStatus status) private void UpdatePowerText() => PowerText.Text = $"Power: {GetPowerSource()}"; + private static string GetSupplyDisplayName(SupplyType supplyType) => supplyType switch + { + SupplyType.HighPowerAc => "verified high-power AC", + SupplyType.LowPowerPd => "USB-C PD / low-power AC", + SupplyType.UnknownAc => "AC (unverified)", + SupplyType.Battery => "battery", + _ => "unknown" + }; + private static PowerSource GetPowerSource() => GetSystemPowerStatus(out var status) ? status.ACLineStatus switch { 1 => PowerSource.Ac, 0 => PowerSource.Battery, _ => PowerSource.Unknown } : PowerSource.Unknown; diff --git a/src/OpenSynapse.Core/Models.cs b/src/OpenSynapse.Core/Models.cs index 0b90dcf..9164e1a 100644 --- a/src/OpenSynapse.Core/Models.cs +++ b/src/OpenSynapse.Core/Models.cs @@ -20,13 +20,44 @@ public enum PowerSource Battery } +public enum SupplyType +{ + Unknown, + HighPowerAc, + LowPowerPd, + UnknownAc, + Battery +} + +public sealed record PowerSnapshot( + PowerSource Source, + SupplyType SupplyType, + int? BatteryPercent = null, + double? AdapterLimitWatts = null); + +public static class SupplyClassifier +{ + public const double HighPowerAdapterThresholdWatts = 130; + public const double LowPowerAdapterThresholdWatts = 100; + + public static SupplyType Resolve(PowerSource source, double? adapterLimitWatts) => source switch + { + PowerSource.Battery => SupplyType.Battery, + PowerSource.Unknown => SupplyType.Unknown, + _ when adapterLimitWatts is null || !double.IsFinite(adapterLimitWatts.Value) => SupplyType.UnknownAc, + _ when adapterLimitWatts >= HighPowerAdapterThresholdWatts => SupplyType.HighPowerAc, + _ when adapterLimitWatts <= LowPowerAdapterThresholdWatts => SupplyType.LowPowerPd, + _ => SupplyType.UnknownAc + }; +} + public static class ModeSelector { - public static OperatingMode Resolve(ModeSelection selection, PowerSource source) => selection switch + public static OperatingMode Resolve(ModeSelection selection, PowerSnapshot power) => selection switch { ModeSelection.Performance => OperatingMode.Performance, ModeSelection.Quiet => OperatingMode.Quiet, - _ when source == PowerSource.Ac => OperatingMode.Performance, + _ when power.SupplyType == SupplyType.HighPowerAc => OperatingMode.Performance, _ => OperatingMode.Quiet }; } @@ -70,6 +101,9 @@ public sealed record AgentStatus( OperatingMode? ActiveMode, ModeSelection Selection, PowerSource PowerSource, + SupplyType SupplyType, + int? BatteryPercent, + double? AdapterLimitWatts, IReadOnlyList RazerDevices); public sealed record AgentResponse( diff --git a/tests/OpenSynapse.Agent.Tests/PowerSupplyProbeTests.cs b/tests/OpenSynapse.Agent.Tests/PowerSupplyProbeTests.cs new file mode 100644 index 0000000..13ac4e8 --- /dev/null +++ b/tests/OpenSynapse.Agent.Tests/PowerSupplyProbeTests.cs @@ -0,0 +1,77 @@ +using OpenSynapse.Core; + +namespace OpenSynapse.Agent.Tests; + +[TestClass] +public sealed class PowerSupplyProbeTests +{ + [TestMethod] + public void GetSnapshotCachesAcProbeForFiveMinutes() + { + var now = new DateTimeOffset(2026, 7, 20, 0, 0, 0, TimeSpan.Zero); + var probeCount = 0; + var probe = new PowerSupplyProbe( + () => new SystemPowerSnapshot(PowerSource.Ac, 76), + () => { probeCount++; return 160; }, + () => now, + TimeSpan.FromMinutes(5)); + + var first = probe.GetSnapshot(); + now = now.AddMinutes(4); + var cached = probe.GetSnapshot(); + now = now.AddMinutes(1); + var refreshed = probe.GetSnapshot(); + + Assert.AreEqual(SupplyType.HighPowerAc, first.SupplyType); + Assert.AreEqual(160, cached.AdapterLimitWatts); + Assert.AreEqual(160, refreshed.AdapterLimitWatts); + Assert.AreEqual(2, probeCount); + } + + [TestMethod] + public void GetSnapshotInvalidatesAdapterCacheAfterBatteryUse() + { + var source = PowerSource.Ac; + var probeCount = 0; + var probe = new PowerSupplyProbe( + () => new SystemPowerSnapshot(source, 49), + () => { probeCount++; return 70; }, + () => DateTimeOffset.UtcNow, + TimeSpan.FromMinutes(5)); + + var ac = probe.GetSnapshot(); + source = PowerSource.Battery; + var battery = probe.GetSnapshot(); + source = PowerSource.Ac; + _ = probe.GetSnapshot(); + + Assert.AreEqual(SupplyType.LowPowerPd, ac.SupplyType); + Assert.AreEqual(SupplyType.Battery, battery.SupplyType); + Assert.IsNull(battery.AdapterLimitWatts); + Assert.AreEqual(2, probeCount); + } + + [TestMethod] + public void GetSnapshotFailsSafeWhenAdapterProbeThrows() + { + var probe = new PowerSupplyProbe( + () => new SystemPowerSnapshot(PowerSource.Ac, 100), + () => throw new InvalidOperationException("nvidia-smi failed"), + () => DateTimeOffset.UtcNow, + TimeSpan.FromMinutes(5)); + + var snapshot = probe.GetSnapshot(); + + Assert.AreEqual(SupplyType.UnknownAc, snapshot.SupplyType); + Assert.IsNull(snapshot.AdapterLimitWatts); + } + + [DataRow("160.00", 160.0)] + [DataRow("70.5\r\n80.0", 70.5)] + [DataRow("not supported", null)] + [TestMethod] + public void ParseAdapterLimitReadsTheFirstInvariantValue(string output, double? expected) + { + Assert.AreEqual(expected, PowerSupplyProbe.ParseAdapterLimit(output)); + } +} diff --git a/tests/OpenSynapse.Core.Tests/ModeSelectorTests.cs b/tests/OpenSynapse.Core.Tests/ModeSelectorTests.cs index 7dc193c..e12244c 100644 --- a/tests/OpenSynapse.Core.Tests/ModeSelectorTests.cs +++ b/tests/OpenSynapse.Core.Tests/ModeSelectorTests.cs @@ -5,17 +5,21 @@ namespace OpenSynapse.Core.Tests; [TestClass] public sealed class ModeSelectorTests { - [DataRow(ModeSelection.Auto, PowerSource.Ac, OperatingMode.Performance)] - [DataRow(ModeSelection.Auto, PowerSource.Battery, OperatingMode.Quiet)] - [DataRow(ModeSelection.Auto, PowerSource.Unknown, OperatingMode.Quiet)] - [DataRow(ModeSelection.Performance, PowerSource.Battery, OperatingMode.Performance)] - [DataRow(ModeSelection.Quiet, PowerSource.Ac, OperatingMode.Quiet)] + [DataRow(ModeSelection.Auto, SupplyType.HighPowerAc, OperatingMode.Performance)] + [DataRow(ModeSelection.Auto, SupplyType.LowPowerPd, OperatingMode.Quiet)] + [DataRow(ModeSelection.Auto, SupplyType.UnknownAc, OperatingMode.Quiet)] + [DataRow(ModeSelection.Auto, SupplyType.Battery, OperatingMode.Quiet)] + [DataRow(ModeSelection.Auto, SupplyType.Unknown, OperatingMode.Quiet)] + [DataRow(ModeSelection.Performance, SupplyType.Battery, OperatingMode.Performance)] + [DataRow(ModeSelection.Quiet, SupplyType.HighPowerAc, OperatingMode.Quiet)] [TestMethod] public void ResolveReturnsExpectedMode( ModeSelection selection, - PowerSource source, + SupplyType supplyType, OperatingMode expected) { - Assert.AreEqual(expected, ModeSelector.Resolve(selection, source)); + var snapshot = new PowerSnapshot(PowerSource.Unknown, supplyType); + + Assert.AreEqual(expected, ModeSelector.Resolve(selection, snapshot)); } } diff --git a/tests/OpenSynapse.Core.Tests/SupplyClassifierTests.cs b/tests/OpenSynapse.Core.Tests/SupplyClassifierTests.cs new file mode 100644 index 0000000..24572d6 --- /dev/null +++ b/tests/OpenSynapse.Core.Tests/SupplyClassifierTests.cs @@ -0,0 +1,32 @@ +using OpenSynapse.Core; + +namespace OpenSynapse.Core.Tests; + +[TestClass] +public sealed class SupplyClassifierTests +{ + [DataRow(PowerSource.Battery, null, SupplyType.Battery)] + [DataRow(PowerSource.Unknown, null, SupplyType.Unknown)] + [DataRow(PowerSource.Ac, null, SupplyType.UnknownAc)] + [DataRow(PowerSource.Ac, 70.0, SupplyType.LowPowerPd)] + [DataRow(PowerSource.Ac, 100.0, SupplyType.LowPowerPd)] + [DataRow(PowerSource.Ac, 100.1, SupplyType.UnknownAc)] + [DataRow(PowerSource.Ac, 129.9, SupplyType.UnknownAc)] + [DataRow(PowerSource.Ac, 130.0, SupplyType.HighPowerAc)] + [DataRow(PowerSource.Ac, 160.0, SupplyType.HighPowerAc)] + [TestMethod] + public void ResolveUsesFailSafeAdapterBoundaries( + PowerSource source, + double? adapterLimitWatts, + SupplyType expected) + { + Assert.AreEqual(expected, SupplyClassifier.Resolve(source, adapterLimitWatts)); + } + + [TestMethod] + public void ResolveTreatsNonFinitePowerLimitsAsUnknownAc() + { + Assert.AreEqual(SupplyType.UnknownAc, SupplyClassifier.Resolve(PowerSource.Ac, double.NaN)); + Assert.AreEqual(SupplyType.UnknownAc, SupplyClassifier.Resolve(PowerSource.Ac, double.PositiveInfinity)); + } +} From 150e5461ea0809d3ba2a8044a6aa439648a20f7b Mon Sep 17 00:00:00 2001 From: MoveLikeYager Date: Mon, 20 Jul 2026 17:28:26 +0800 Subject: [PATCH 03/17] feat: add balanced operating mode --- CONTEXT.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/ARCHITECTURE.md | 6 ++ scripts/Test-Milestones.ps1 | 10 +++ src/OpenSynapse.Agent/AgentController.cs | 33 +++++++-- src/OpenSynapse.Agent/AgentServer.cs | 20 ++++- src/OpenSynapse.Agent/DisplayPolicy.cs | 12 +-- src/OpenSynapse.Agent/OpenSynapseState.cs | 3 +- src/OpenSynapse.Agent/PowerPlanManager.cs | 74 +++++++++++++------ src/OpenSynapse.Agent/Program.cs | 6 +- .../Windows/DisplayNative.cs | 2 +- src/OpenSynapse.Agent/WindowsDisplaySystem.cs | 4 +- src/OpenSynapse.App/MainWindow.xaml | 2 +- src/OpenSynapse.App/MainWindow.xaml.cs | 12 +-- src/OpenSynapse.Core/Models.cs | 9 +++ .../DisplayPolicyTests.cs | 25 ++++++- .../PowerPlanPolicyTests.cs | 58 +++++++++++++++ .../StateStoreTests.cs | 20 +++++ .../ModeSelectorTests.cs | 15 ++++ 20 files changed, 262 insertions(+), 59 deletions(-) create mode 100644 tests/OpenSynapse.Agent.Tests/PowerPlanPolicyTests.cs 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/README.md b/README.md index c04ee9b..f8010e9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ OpenSynapse currently implements the M0–M3 development slice. Implementation d | Area | Current capability | Maturity | | --- | --- | --- | -| Windows policies | Adapter-aware Auto, Performance, and Quiet selection; power plans; refresh rate; Advanced Color/HDR; internal brightness; display scaling | Implemented, target-Windows validation pending | +| Windows policies | Adapter-aware Auto, Performance, Balanced, 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 | | Razer mouse | Discovery, status, DPI, and standard-receiver polling control | Experimental | @@ -83,7 +83,7 @@ powershell -ExecutionPolicy Bypass -File scripts\Test-Milestones.ps1 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 script verifies Performance/Balanced/Quiet application (Balanced when battery is at least 50%), 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. ## Safety and privacy diff --git a/ROADMAP.md b/ROADMAP.md index 68298bf..b6c8276 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ The roadmap describes validation gates, not delivery dates. A capability moves f - [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] Adapter-aware Auto, Performance, Balanced, and Quiet policy selection. - [x] Captured state and power-plan rollback. - [x] DeathAdder V3 Pro discovery, status, DPI, and standard polling commands. - [ ] Run the reversible Windows policy smoke test on the target machine. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 573dd08..cd0d889 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,10 +44,16 @@ stateDiagram-v2 [*] --> Unmanaged Unmanaged --> Captured: first policy application Captured --> Performance: apply Performance + Captured --> Balanced: apply Balanced Captured --> Quiet: apply Quiet + Performance --> Balanced: selection changes Performance --> Quiet: selection or power source changes + Balanced --> Performance: selection changes + Balanced --> Quiet: selection or battery below 50% Quiet --> Performance: selection or power source changes + Quiet --> Balanced: eligible selection Performance --> Restoring: restore or shutdown + Balanced --> Restoring: restore or shutdown Quiet --> Restoring: restore or shutdown Restoring --> Unmanaged: confirmed restoration Restoring --> Captured: any restoration remains pending diff --git a/scripts/Test-Milestones.ps1 b/scripts/Test-Milestones.ps1 index a1a49b9..b925f11 100644 --- a/scripts/Test-Milestones.ps1 +++ b/scripts/Test-Milestones.ps1 @@ -61,6 +61,16 @@ try { throw 'Performance mode verification failed.' } + if ($status.Status.BatteryPercent -ge 50) { + $balanced = Invoke-Agent apply Balanced + if (-not $balanced.Success -or $balanced.Status.ActiveMode -ne 'Balanced') { + throw 'Balanced mode verification failed.' + } + } + else { + Write-Warning 'Balanced mode verification skipped because battery is below 50% or unavailable.' + } + $quiet = Invoke-Agent apply Quiet if (-not $quiet.Success -or $quiet.Status.ActiveMode -ne 'Quiet') { throw 'Quiet mode verification failed.' diff --git a/src/OpenSynapse.Agent/AgentController.cs b/src/OpenSynapse.Agent/AgentController.cs index 83f60a1..b4de91f 100644 --- a/src/OpenSynapse.Agent/AgentController.cs +++ b/src/OpenSynapse.Agent/AgentController.cs @@ -22,7 +22,7 @@ public Task HandleAsync(AgentRequest request) } } - public void ApplyCurrentSelection() + public void ApplyCurrentSelection(bool force = false) { lock (gate) { @@ -31,7 +31,14 @@ public void ApplyCurrentSelection() { var state = store.Load(); var powerSnapshot = powerSupply.GetSnapshot(); - ApplyMode(ModeSelector.Resolve(state.Selection, powerSnapshot), state, powerSnapshot); + if (state.Selection == ModeSelection.Balanced && !ModeSelector.IsBalancedEligible(powerSnapshot)) + { + state.Selection = ModeSelection.Quiet; + store.Save(state); + } + var desiredMode = ModeSelector.Resolve(state.Selection, powerSnapshot); + if (!force && state.ActiveMode == desiredMode) return; + ApplyMode(desiredMode, state, powerSnapshot); } catch (Exception ex) { @@ -50,12 +57,20 @@ private AgentResponse Handle(AgentRequest request) return new AgentResponse(true, "OK", GetStatus(state)); 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); + return ApplyMode(mode, state, applyPowerSnapshot); case AgentOperation.SetSelection: - state.Selection = request.Selection ?? throw new ArgumentException("Selection is required."); - store.Save(state); + var selection = request.Selection ?? throw new ArgumentException("Selection is required."); var powerSnapshot = powerSupply.GetSnapshot(); + if (selection == ModeSelection.Balanced && !ModeSelector.IsBalancedEligible(powerSnapshot)) + throw new InvalidOperationException( + $"Balanced mode requires at least {ModeSelector.BalancedBatteryThresholdPercent}% battery; " + + $"current charge is {powerSnapshot.BatteryPercent?.ToString() ?? "unavailable"}%."); + state.Selection = selection; + store.Save(state); return ApplyMode(ModeSelector.Resolve(state.Selection, powerSnapshot), state, powerSnapshot); case AgentOperation.Restore: @@ -138,4 +153,12 @@ private static void RequireAdministrator() if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) throw new UnauthorizedAccessException("Applying or restoring Windows policies requires an elevated OpenSynapse.Agent."); } + + private static void EnsureBalancedEligible(OperatingMode mode, PowerSnapshot powerSnapshot) + { + if (mode != OperatingMode.Balanced || ModeSelector.IsBalancedEligible(powerSnapshot)) return; + throw new InvalidOperationException( + $"Balanced mode requires at least {ModeSelector.BalancedBatteryThresholdPercent}% battery; " + + $"current charge is {powerSnapshot.BatteryPercent?.ToString() ?? "unavailable"}%."); + } } diff --git a/src/OpenSynapse.Agent/AgentServer.cs b/src/OpenSynapse.Agent/AgentServer.cs index bda29e8..6d93d57 100644 --- a/src/OpenSynapse.Agent/AgentServer.cs +++ b/src/OpenSynapse.Agent/AgentServer.cs @@ -10,7 +10,9 @@ internal sealed class AgentServer(AgentController controller) public async Task RunAsync(CancellationToken cancellationToken) { SystemEvents.PowerModeChanged += PowerModeChanged; - controller.ApplyCurrentSelection(); + controller.ApplyCurrentSelection(force: true); + using var monitorCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var monitor = MonitorSelectionAsync(monitorCancellation.Token); try { while (!cancellationToken.IsCancellationRequested) @@ -25,12 +27,24 @@ public async Task RunAsync(CancellationToken cancellationToken) if (await HandleConnectionAsync(pipe, cancellationToken)) break; } } - finally { SystemEvents.PowerModeChanged -= PowerModeChanged; } + finally + { + monitorCancellation.Cancel(); + try { await monitor; } catch (OperationCanceledException) { } + SystemEvents.PowerModeChanged -= PowerModeChanged; + } } 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) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); + while (await timer.WaitForNextTickAsync(cancellationToken)) + controller.ApplyCurrentSelection(); } private async Task HandleConnectionAsync(Stream stream, CancellationToken cancellationToken) diff --git a/src/OpenSynapse.Agent/DisplayPolicy.cs b/src/OpenSynapse.Agent/DisplayPolicy.cs index daa2e5c..ae8ccb0 100644 --- a/src/OpenSynapse.Agent/DisplayPolicy.cs +++ b/src/OpenSynapse.Agent/DisplayPolicy.cs @@ -18,10 +18,10 @@ internal DisplayPolicy(IDisplaySystem displaySystem) public void Capture(OperatingMode mode, OpenSynapseState state) { CaptureDisplayScales(state); - if (mode == OperatingMode.Quiet) CaptureForQuiet(state); + if (mode != OperatingMode.Performance) CaptureForLowPower(state); } - public void CaptureForQuiet(OpenSynapseState state) + private void CaptureForLowPower(OpenSynapseState state) { if (state.AdvancedColors.Count == 0) { @@ -42,13 +42,13 @@ public void CaptureForQuiet(OpenSynapseState state) public void Apply(OperatingMode mode, OpenSynapseState state) { - if (mode == OperatingMode.Quiet) + if (mode != OperatingMode.Performance) { - CaptureForQuiet(state); + CaptureForLowPower(state); foreach (var color in state.AdvancedColors) try { displaySystem.SetAdvancedColor(color.Key, false); } catch { } - try { displaySystem.SetBrightness(40); } catch { } - try { displaySystem.ApplyQuietRefresh(60); } catch { } + try { displaySystem.SetBrightness(mode == OperatingMode.Balanced ? 60 : 40); } catch { } + try { displaySystem.ApplyFixedRefresh(mode == OperatingMode.Balanced ? 120 : 60); } catch { } } else { diff --git a/src/OpenSynapse.Agent/OpenSynapseState.cs b/src/OpenSynapse.Agent/OpenSynapseState.cs index 80dbd01..b59c09a 100644 --- a/src/OpenSynapse.Agent/OpenSynapseState.cs +++ b/src/OpenSynapse.Agent/OpenSynapseState.cs @@ -5,12 +5,13 @@ namespace OpenSynapse.Agent; internal sealed class OpenSynapseState { - public const int CurrentSchemaVersion = 1; + public const int CurrentSchemaVersion = 2; public int SchemaVersion { get; set; } = CurrentSchemaVersion; public ModeSelection Selection { get; set; } = ModeSelection.Auto; 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; } diff --git a/src/OpenSynapse.Agent/PowerPlanManager.cs b/src/OpenSynapse.Agent/PowerPlanManager.cs index e1d211d..87f8a29 100644 --- a/src/OpenSynapse.Agent/PowerPlanManager.cs +++ b/src/OpenSynapse.Agent/PowerPlanManager.cs @@ -3,26 +3,45 @@ 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) +{ + 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 const string BalancedScheme = "381b4222-f694-41f0-9685-ff5bb260df2e"; private readonly string powerCfg = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); - 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, 80), false), + new("54533251-82be-4824-96c1-47b60b740d00", "36687f9e-e3a5-4dbf-b1dc-15eb381c6863", new(0, 20), new(50, 70), new(90, 90), 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) ]; public string GetActiveGuid() => ParseGuid(Run("/getactivescheme")); @@ -31,7 +50,13 @@ 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)) @@ -59,6 +84,12 @@ private void EnsurePlans(OpenSynapseState state) Configure(plan, OperatingMode.Performance); state.PerformancePowerPlan = plan; } + if (!Exists(state.BalancedPowerPlan)) + { + var plan = Duplicate("OpenSynapse Balanced"); + Configure(plan, OperatingMode.Balanced); + state.BalancedPowerPlan = plan; + } if (!Exists(state.QuietPowerPlan)) { var plan = Duplicate("OpenSynapse Quiet"); @@ -69,21 +100,20 @@ 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; + 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) { } } diff --git a/src/OpenSynapse.Agent/Program.cs b/src/OpenSynapse.Agent/Program.cs index 92ba307..87c8bce 100644 --- a/src/OpenSynapse.Agent/Program.cs +++ b/src/OpenSynapse.Agent/Program.cs @@ -20,14 +20,16 @@ "status" => new AgentRequest(AgentOperation.Status), "devices" => new AgentRequest(AgentOperation.ListDevices), "restore" => new AgentRequest(AgentOperation.Restore), - "apply" when args.Length == 2 && Enum.TryParse(args[1], true, out var mode) + "apply" when args.Length == 2 + && Enum.TryParse(args[1], true, out var mode) + && Enum.IsDefined(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|devices|apply |restore|mouse-dpi <100..30000>|mouse-polling <125|500|1000>]") }; var response = await controller.HandleAsync(request); diff --git a/src/OpenSynapse.Agent/Windows/DisplayNative.cs b/src/OpenSynapse.Agent/Windows/DisplayNative.cs index 35a0645..2f1c9a3 100644 --- a/src/OpenSynapse.Agent/Windows/DisplayNative.cs +++ b/src/OpenSynapse.Agent/Windows/DisplayNative.cs @@ -518,7 +518,7 @@ public static int ApplyMaximumRefresh() return changed; } - public static int ApplyQuietRefresh(int targetHz) + public static int ApplyFixedRefresh(int targetHz) { int changed = 0; foreach (DISPLAY_DEVICE device in GetActiveDevices()) diff --git a/src/OpenSynapse.Agent/WindowsDisplaySystem.cs b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs index df61488..f42585b 100644 --- a/src/OpenSynapse.Agent/WindowsDisplaySystem.cs +++ b/src/OpenSynapse.Agent/WindowsDisplaySystem.cs @@ -14,7 +14,7 @@ internal interface IDisplaySystem IReadOnlyList GetDisplays(); void SetDisplayScale(string key, int desiredPercent); void ApplyMaximumRefresh(); - void ApplyQuietRefresh(int targetHz); + void ApplyFixedRefresh(int targetHz); void RestoreRefresh(); } @@ -53,7 +53,7 @@ public void SetDisplayScale(string key, int desiredPercent) public void ApplyMaximumRefresh() => _ = DisplayModeManager.ApplyMaximumRefresh(); - public void ApplyQuietRefresh(int targetHz) => _ = DisplayModeManager.ApplyQuietRefresh(targetHz); + public void ApplyFixedRefresh(int targetHz) => _ = DisplayModeManager.ApplyFixedRefresh(targetHz); public void RestoreRefresh() => DisplayModeManager.RestoreRegistryModes(); diff --git a/src/OpenSynapse.App/MainWindow.xaml b/src/OpenSynapse.App/MainWindow.xaml index 71314dc..3d838da 100644 --- a/src/OpenSynapse.App/MainWindow.xaml +++ b/src/OpenSynapse.App/MainWindow.xaml @@ -36,7 +36,7 @@