Skip to content

Commit d223a08

Browse files
author
Yu Leng
committed
feat(powerdisplay): add headless CLI to control monitor settings
Adds PowerToys.PowerDisplay.Cli.exe, a headless command-line front end for the PowerDisplay module. The CLI is a thin IPC client: it connects to the running PowerDisplay app over a per-session named pipe and the app performs all hardware work (DDC/CI and GDI writes) against its live monitor cache, so the CLI itself needs no elevation or direct hardware access. Projects: - PowerDisplay.Contracts - shared request/result/error DTOs, the stable exit/error codes, the named-pipe name + line framing, and a System.Text.Json source-gen context (AOT-safe). Single source for setting names, command names, and the code->exit-code mapping; responses carry an explicit IsError discriminator. - PowerDisplay.Cli - System.CommandLine front end: parse -> CLI-side validation -> send the request over the pipe -> render human-readable text -> map the result to a process exit code. UTF-8 output, --timeout/--quiet, localized strings. - PowerDisplay (app) - named-pipe server with a cross-integrity ACL so a non-elevated CLI can reach an elevated app; a request handler that marshals onto the UI thread; and projectors/executor that turn the monitor model into DTOs and apply set / apply-profile writes with capability validation. Commands: list, get, set, capabilities, profiles, apply-profile - with -n/-i monitor selectors, --setting filter, and --confirm-power-off gating for display-blanking power states. Exit codes 0-10 are a stable contract (10 = PowerDisplay not running). Covered by Contracts/Cli/Ipc unit tests.
1 parent be4c3a1 commit d223a08

85 files changed

Lines changed: 7727 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/spell-check/expect.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ CAPTURECHANGED
184184
CARETBLINKING
185185
carlos
186186
Carlseibert
187+
caseinsensitive
187188
caub
188189
CBN
189190
cch
@@ -432,6 +433,7 @@ downsampling
432433
downscale
433434
DPICHANGED
434435
DPIs
436+
dpm
435437
DPMS
436438
DPSAPI
437439
DQTAT
@@ -501,6 +503,7 @@ EREOF
501503
EResize
502504
ERRORIMAGE
503505
ERRORTITLE
506+
esac
504507
esrp
505508
etd
506509
ETDT
@@ -673,6 +676,7 @@ hcursor
673676
hcwhite
674677
hdc
675678
HDEVNOTIFY
679+
hdmi
676680
hdr
677681
HDROP
678682
hdwwiz
@@ -1243,6 +1247,7 @@ NTSTATUS
12431247
NTSYSAPI
12441248
nullability
12451249
NULLCURSOR
1250+
nullid
12461251
nullonfailure
12471252
nullref
12481253
numberbox
@@ -1305,6 +1310,7 @@ PARENTRELATIVEFORADDRESSBAR
13051310
PARENTRELATIVEFORUI
13061311
PARENTRELATIVEPARSING
13071312
parray
1313+
parseable
13081314
PARTIALCONFIRMATIONDIALOGTITLE
13091315
PATCOPY
13101316
PATHMUSTEXIST
@@ -1542,6 +1548,7 @@ Removelnk
15421548
renamable
15431549
RENAMEONCOLLISION
15441550
RENDERFULLCONTENT
1551+
renumbers
15451552
reparented
15461553
reparenting
15471554
reportfileaccesses
@@ -1555,6 +1562,7 @@ RESIZETOFIT
15551562
resmimetype
15561563
RESOURCEID
15571564
RESTORETOMAXIMIZED
1565+
resx
15581566
RETURNONLYFSDIRS
15591567
Revalidates
15601568
RGBQUAD
@@ -1949,6 +1957,7 @@ ums
19491957
uncompilable
19501958
UNCPRIORITY
19511959
UNDNAME
1960+
unescaped
19521961
ungroup
19531962
UNICODETEXT
19541963
unins
@@ -1961,6 +1970,7 @@ unittests
19611970
UNLEN
19621971
UNORM
19631972
unparsable
1973+
unparseable
19641974
unremapped
19651975
Unsend
19661976
Unsubscribes

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,4 @@ deps/vcpkg/
381381

382382
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
383383
docs/superpowers/
384+
.superpowers/

.pipelines/ESRPSigning_core.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@
221221
"PowerToys.PowerDisplayModuleInterface.dll",
222222
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
223223
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
224+
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.exe",
225+
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.dll",
224226
"PowerDisplay.Lib.dll",
225227
"PowerDisplay.Models.dll",
226228

PowerToys.slnx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,10 @@
722722
<Platform Solution="*|ARM64" Project="ARM64" />
723723
<Platform Solution="*|x64" Project="x64" />
724724
</Project>
725+
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts/PowerDisplay.Contracts.csproj">
726+
<Platform Solution="*|ARM64" Project="ARM64" />
727+
<Platform Solution="*|x64" Project="x64" />
728+
</Project>
725729
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
726730
<Platform Solution="*|ARM64" Project="ARM64" />
727731
<Platform Solution="*|x64" Project="x64" />
@@ -730,13 +734,29 @@
730734
<Platform Solution="*|ARM64" Project="ARM64" />
731735
<Platform Solution="*|x64" Project="x64" />
732736
</Project>
737+
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli/PowerDisplay.Cli.csproj">
738+
<Platform Solution="*|ARM64" Project="ARM64" />
739+
<Platform Solution="*|x64" Project="x64" />
740+
</Project>
733741
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
734742
</Folder>
735743
<Folder Name="/modules/PowerDisplay/Tests/">
736744
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj">
737745
<Platform Solution="*|ARM64" Project="ARM64" />
738746
<Platform Solution="*|x64" Project="x64" />
739747
</Project>
748+
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli.UnitTests/PowerDisplay.Cli.UnitTests.csproj">
749+
<Platform Solution="*|ARM64" Project="ARM64" />
750+
<Platform Solution="*|x64" Project="x64" />
751+
</Project>
752+
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts.UnitTests/PowerDisplay.Contracts.UnitTests.csproj">
753+
<Platform Solution="*|ARM64" Project="ARM64" />
754+
<Platform Solution="*|x64" Project="x64" />
755+
</Project>
756+
<Project Path="src/modules/powerdisplay/PowerDisplay.Ipc.UnitTests/PowerDisplay.Ipc.UnitTests.csproj">
757+
<Platform Solution="*|ARM64" Project="ARM64" />
758+
<Platform Solution="*|x64" Project="x64" />
759+
</Project>
740760
</Folder>
741761
<Folder Name="/modules/MeasureTool/">
742762
<Project Path="src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj" Id="54a93af7-60c7-4f6c-99d2-fbb1f75f853a">

installer/PowerToysSetupVNext/Resources.wxs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,12 @@
367367
</RegistryKey>
368368
<File Id="CmdPalExtPowerToys_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\Microsoft.CmdPal.Ext.PowerToys.resources.dll" />
369369
</Component>
370+
<Component Id="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Guid="$(var.CompGUIDPrefix)24">
371+
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
372+
<RegistryValue Type="string" Name="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" />
373+
</RegistryKey>
374+
<File Id="PowerDisplayCli_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\PowerToys.PowerDisplay.Cli.resources.dll" />
375+
</Component>
370376
<?undef IdSafeLanguage?>
371377
<?undef CompGUIDPrefix?>
372378
<?endforeach?>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.IO;
7+
using System.IO.Pipes;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.VisualStudio.TestTools.UnitTesting;
11+
using PowerDisplay.Cli.Ipc;
12+
using PowerDisplay.Contracts;
13+
14+
namespace PowerDisplay.Cli.UnitTests;
15+
16+
/// <summary>
17+
/// Tests for <see cref="CliPipeClient"/>.
18+
/// </summary>
19+
[TestClass]
20+
public class CliPipeClientTests
21+
{
22+
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
23+
24+
private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(200);
25+
26+
// ── Happy-path: in-proc fake server ──────────────────────────────────────
27+
[TestMethod]
28+
[Timeout(10_000)]
29+
public async Task SendAsync_WithFakeServer_ReturnsCannedResponse()
30+
{
31+
const string RequestJson = @"{""command"":""list""}";
32+
const string ResponseJson = @"{""monitors"":[]}";
33+
34+
// Start a one-shot in-proc server on the same pipe name
35+
using var serverReady = new SemaphoreSlim(0, 1);
36+
var serverTask = Task.Run(async () =>
37+
{
38+
using var server = new NamedPipeServerStream(
39+
PipeNames.CliServer(),
40+
PipeDirection.InOut,
41+
1,
42+
PipeTransmissionMode.Byte,
43+
PipeOptions.Asynchronous);
44+
45+
serverReady.Release(); // signal: server is now listening
46+
await server.WaitForConnectionAsync();
47+
48+
// Mirror the server protocol: BOM-less UTF-16 LE (same as CliPipeClient / CliPipeServer).
49+
// Use the shared pipe encoding/buffer so the fake server stays byte-compatible with the client.
50+
using var reader = new StreamReader(server, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
51+
using var writer = new StreamWriter(server, CliPipeProtocol.PipeEncoding, CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
52+
53+
var line = await reader.ReadLineAsync();
54+
55+
// Echo back the canned response regardless of what was sent
56+
await writer.WriteLineAsync(ResponseJson);
57+
});
58+
59+
// Wait until the server is listening before connecting
60+
await serverReady.WaitAsync(TimeSpan.FromSeconds(5));
61+
62+
var client = new CliPipeClient();
63+
var result = await client.SendAsync(RequestJson, ConnectTimeout, CancellationToken.None);
64+
65+
await serverTask; // ensure the server task completes cleanly
66+
67+
Assert.AreEqual(ResponseJson, result);
68+
}
69+
70+
// ── No-server path: returns null within short timeout ────────────────────
71+
[TestMethod]
72+
[Timeout(5_000)]
73+
public async Task SendAsync_NoServer_ReturnsNullWithinShortTimeout()
74+
{
75+
// There is no server listening on this pipe, so ConnectAsync will throw TimeoutException.
76+
// We use ShortTimeout (200 ms) to keep the test fast.
77+
var client = new CliPipeClient();
78+
var result = await client.SendAsync(@"{""command"":""list""}", ShortTimeout, CancellationToken.None);
79+
80+
Assert.IsNull(result, "Expected null when no pipe server is running");
81+
}
82+
83+
// ── Cancellation propagates ───────────────────────────────────────────────
84+
[TestMethod]
85+
[Timeout(5_000)]
86+
public async Task SendAsync_CancelledToken_ThrowsOperationCanceledException()
87+
{
88+
using var cts = new CancellationTokenSource();
89+
cts.Cancel(); // pre-cancelled
90+
91+
var client = new CliPipeClient();
92+
93+
// Assert.ThrowsExceptionAsync<T> matches the exact type, so TaskCanceledException
94+
// (which derives from OperationCanceledException) would fail it. Use a manual
95+
// try/catch so any subclass of OperationCanceledException is accepted.
96+
try
97+
{
98+
await client.SendAsync(@"{""command"":""list""}", ConnectTimeout, cts.Token);
99+
Assert.Fail("Expected the operation to be cancelled.");
100+
}
101+
catch (OperationCanceledException)
102+
{
103+
// expected (TaskCanceledException derives from OperationCanceledException)
104+
}
105+
}
106+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
using PowerDisplay.Contracts;
7+
8+
namespace PowerDisplay.Cli.UnitTests;
9+
10+
[TestClass]
11+
public class ExitCodeMatrixTests
12+
{
13+
[DataTestMethod]
14+
[DataRow(CliExitCodes.Ok, 0)]
15+
[DataRow(CliExitCodes.MonitorNotFound, 1)]
16+
[DataRow(CliExitCodes.OutOfRange, 2)]
17+
[DataRow(CliExitCodes.InvalidDiscreteValue, 3)]
18+
[DataRow(CliExitCodes.UnsupportedFeature, 4)]
19+
[DataRow(CliExitCodes.HardwareFailure, 5)]
20+
[DataRow(CliExitCodes.SelectorMissing, 6)]
21+
[DataRow(CliExitCodes.ArgumentError, 7)]
22+
[DataRow(CliExitCodes.Timeout, 8)]
23+
[DataRow(CliExitCodes.InternalError, 9)]
24+
public void ExitCodes_AreStable(int actual, int expected) => Assert.AreEqual(expected, actual);
25+
}

0 commit comments

Comments
 (0)