feat(powerdisplay): add CLI for monitor control (#48632)

## Summary of the Pull Request

Adds `PowerToys.PowerDisplay.Cli.exe`, a scriptable interface for
controlling monitors through the running PowerDisplay process. It
supports `list`, `get`, `set`, `up`, `down`, `capabilities`, `profiles`,
and `apply-profile`.

The CLI communicates over an authenticated, per-session named pipe;
PowerDisplay remains responsible for DDC/CI and WMI access.

## PR Checklist

- [x] Closes: #48713
- [x] **Communication:** Discussed with core contributors
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** Core errors are localizable; some help and
output text remains English-only
- [ ] **Dev docs:** N/A; built-in CLI help is the command reference
- [x] **New binaries:** Added on the required places
- [x] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
- [x] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetupVNext/Resources.wxs)
- [x] **YML for CI pipeline:** N/A; test assemblies are auto-discovered
- [x] **YML for signed pipeline:** N/A; signing is driven by
`ESRPSigning_core.json`
- [ ] **Documentation updated:** N/A

## Detailed Description of the Pull Request / Additional comments

- Adds an AOT-compatible CLI and shared request/response contracts.
- Uses a secured named-pipe server in PowerDisplay, with stable exit
codes and a bounded request timeout.
- Supports saved profiles by their existing stable profile IDs.
- Adds solution, signing, installer, and unit-test project integration.

## Validation Steps Performed

- PowerDisplay Lib, Contracts, CLI, and IPC unit-test suites pass.
- Native AOT publish completes without analyzer warnings.
- Manually validated the CLI on two DDC/CI monitors, including the
PowerDisplay-unavailable path.
- Rebuilt the PowerDisplay GUI after the shared-library changes.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
moooyo
2026-07-16 23:08:27 +08:00
committed by GitHub
parent 85d0c890de
commit 5c9c93d56d
113 changed files with 10403 additions and 19 deletions

View File

@@ -186,6 +186,7 @@ CAPTURECHANGED
CARETBLINKING
carlos
Carlseibert
caseinsensitive
caub
CBN
cch
@@ -434,6 +435,7 @@ downsampling
downscale
DPICHANGED
DPIs
dpm
DPMS
DPSAPI
DQTAT
@@ -678,6 +680,7 @@ hcursor
hcwhite
hdc
HDEVNOTIFY
hdmi
hdr
HDROP
hdwwiz
@@ -1313,6 +1316,7 @@ PARENTRELATIVEFORADDRESSBAR
PARENTRELATIVEFORUI
PARENTRELATIVEPARSING
parray
parseable
PARTIALCONFIRMATIONDIALOGTITLE
PATCOPY
PATHMUSTEXIST
@@ -1550,6 +1554,7 @@ Removelnk
renamable
RENAMEONCOLLISION
RENDERFULLCONTENT
renumbers
reparented
reparenting
reportfileaccesses
@@ -1563,6 +1568,7 @@ RESIZETOFIT
resmimetype
RESOURCEID
RESTORETOMAXIMIZED
resx
RETURNONLYFSDIRS
Revalidates
RGBQUAD
@@ -1959,6 +1965,7 @@ ums
uncompilable
UNCPRIORITY
UNDNAME
unescaped
ungroup
UNICODETEXT
unins

View File

@@ -221,6 +221,10 @@
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.exe",
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.Contracts.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.Ipc.dll",
"PowerDisplay.Lib.dll",
"PowerDisplay.Models.dll",

View File

@@ -734,6 +734,14 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts/PowerDisplay.Contracts.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Ipc/PowerDisplay.Ipc.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
@@ -742,6 +750,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli/PowerDisplay.Cli.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
</Folder>
<Folder Name="/modules/PowerDisplay/Tests/">
@@ -749,6 +761,18 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli.UnitTests/PowerDisplay.Cli.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts.UnitTests/PowerDisplay.Contracts.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Ipc.UnitTests/PowerDisplay.Ipc.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/MeasureTool/">
<Project Path="src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj" Id="54a93af7-60c7-4f6c-99d2-fbb1f75f853a">
@@ -1171,4 +1195,3 @@
<Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" />
</Solution>

View File

@@ -367,6 +367,12 @@
</RegistryKey>
<File Id="CmdPalExtPowerToys_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\Microsoft.CmdPal.Ext.PowerToys.resources.dll" />
</Component>
<Component Id="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Guid="$(var.CompGUIDPrefix)24">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="PowerDisplayCli_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\PowerToys.PowerDisplay.Cli.resources.dll" />
</Component>
<?undef IdSafeLanguage?>
<?undef CompGUIDPrefix?>
<?endforeach?>

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Commands;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class AdjustCommandInputsTests
{
[TestMethod]
public void CountSelectedSettings_CountsAcrossThresholds()
{
Assert.AreEqual(0, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs()));
Assert.AreEqual(1, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true }));
Assert.AreEqual(2, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true, Volume = true }));
Assert.AreEqual(3, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true, Contrast = true, Volume = true }));
}
}

View File

@@ -0,0 +1,332 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Cli.Options;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for the comma-separated <c>-n</c> batch-monitor feature: option parsing, the per-monitor
/// dispatch/aggregation for the write commands (set/up/down), timeout scaling, and the single-monitor
/// guard for the read commands (get/capabilities).
/// </summary>
[TestClass]
public class BatchMonitorTests
{
private static ParseResult Parse(params string[] args)
=> new Parser(new PowerDisplayRootCommand()).Parse(args);
// ── -n parsing ────────────────────────────────────────────────────────────
[TestMethod]
public void MonitorNumber_CommaSeparated_ParsesAllIndices()
{
var parsed = Parse("set", "-n", "1,2,3", "--brightness", "50");
var expected = new[] { 1, 2, 3 };
Assert.AreEqual(0, parsed.Errors.Count);
CollectionAssert.AreEqual(expected, parsed.GetValueForOption(CliOptions.MonitorNumber));
}
[TestMethod]
public void MonitorNumber_Single_ParsesToOneElement()
{
var parsed = Parse("set", "-n", "3", "--brightness", "50");
var expected = new[] { 3 };
CollectionAssert.AreEqual(expected, parsed.GetValueForOption(CliOptions.MonitorNumber));
}
[TestMethod]
public void MonitorNumber_Duplicates_CollapsedPreservingFirstSeenOrder()
{
var parsed = Parse("up", "--brightness", "-n", "2,1,2");
var expected = new[] { 2, 1 };
Assert.AreEqual(0, parsed.Errors.Count);
CollectionAssert.AreEqual(expected, parsed.GetValueForOption(CliOptions.MonitorNumber));
}
[TestMethod]
public void MonitorNumber_WhitespaceAroundEntries_IsTrimmed()
{
var parsed = Parse("set", "-n", " 1 , 2 ", "--brightness", "50");
var expected = new[] { 1, 2 };
Assert.AreEqual(0, parsed.Errors.Count);
CollectionAssert.AreEqual(expected, parsed.GetValueForOption(CliOptions.MonitorNumber));
}
[TestMethod]
public void MonitorNumber_NonInteger_ProducesParseError()
=> Assert.IsTrue(Parse("set", "-n", "1,abc", "--brightness", "50").Errors.Count > 0);
[TestMethod]
public void MonitorNumber_TrailingComma_ProducesParseError()
=> Assert.IsTrue(Parse("set", "-n", "1,", "--brightness", "50").Errors.Count > 0);
[DataTestMethod]
[DataRow("0")]
[DataRow("-1")]
[DataRow("1,0,2")]
[DataRow("1,-2,3")]
public void MonitorNumber_NonPositive_ProducesParseError(string monitorNumbers)
=> Assert.IsTrue(Parse("set", "-n", monitorNumbers, "--brightness", "50").Errors.Count > 0);
[TestMethod]
public void MonitorNumber_Absent_IsNullOrEmpty()
{
var value = Parse("get").GetValueForOption(CliOptions.MonitorNumber);
Assert.IsTrue(value is null || value.Length == 0);
}
// ── target counting + timeout scaling ─────────────────────────────────────
[TestMethod]
public void CountDispatchTargets_SetBatch_CountsMonitors()
=> Assert.AreEqual(3, Program.CountDispatchTargets(Parse("set", "-n", "1,2,3", "--brightness", "50")));
[TestMethod]
public void CountDispatchTargets_SetSingle_IsOne()
=> Assert.AreEqual(1, Program.CountDispatchTargets(Parse("set", "-n", "2", "--brightness", "50")));
[TestMethod]
public void CountDispatchTargets_MonitorIdWins_IsOne()
=> Assert.AreEqual(1, Program.CountDispatchTargets(Parse("set", "-n", "1,2,3", "-i", "MON", "--brightness", "50")));
[TestMethod]
public void CountDispatchTargets_NonWriteCommand_IsOne()
=> Assert.AreEqual(1, Program.CountDispatchTargets(Parse("get", "-n", "1")));
[TestMethod]
public void ComputeOperationTimeout_ScalesWithTargetCount()
{
Assert.AreEqual(Program.OperationTimeout, Program.ComputeOperationTimeout(1));
Assert.AreEqual(Program.OperationTimeout + Program.PerAdditionalMonitorTimeout, Program.ComputeOperationTimeout(2));
Assert.AreEqual(Program.OperationTimeout + (Program.PerAdditionalMonitorTimeout * 2), Program.ComputeOperationTimeout(3));
// A zero/negative target count must never shrink the base deadline.
Assert.AreEqual(Program.OperationTimeout, Program.ComputeOperationTimeout(0));
}
// ── WorseBatchExit aggregation ────────────────────────────────────────────
[DataTestMethod]
[DataRow(CliExitCodes.Ok, CliExitCodes.Ok, CliExitCodes.Ok)]
[DataRow(CliExitCodes.Ok, CliExitCodes.UnsupportedFeature, CliExitCodes.Ok)]
[DataRow(CliExitCodes.OutOfRange, CliExitCodes.UnsupportedFeature, CliExitCodes.OutOfRange)]
[DataRow(CliExitCodes.Ok, CliExitCodes.MonitorNotFound, CliExitCodes.MonitorNotFound)]
[DataRow(CliExitCodes.MonitorNotFound, CliExitCodes.OutOfRange, CliExitCodes.OutOfRange)]
[DataRow(CliExitCodes.OutOfRange, CliExitCodes.InvalidDiscreteValue, CliExitCodes.InvalidDiscreteValue)]
[DataRow(CliExitCodes.InvalidDiscreteValue, CliExitCodes.HardwareFailure, CliExitCodes.HardwareFailure)]
[DataRow(CliExitCodes.HardwareFailure, CliExitCodes.OutOfRange, CliExitCodes.HardwareFailure)]
public void WorseBatchExit_FoldsToWorstExcludingUnsupported(int current, int next, int expected)
=> Assert.AreEqual(expected, Program.WorseBatchExit(current, next));
// ── DispatchWriteTargetsAsync routing/aggregation ─────────────────────────
[TestMethod]
public async Task DispatchWriteTargets_SingleNumber_DispatchesOnceWithThatNumber()
{
var seen = new List<int?>();
var targets = new[] { 5 };
var exit = await Program.DispatchWriteTargetsAsync(
targets,
monitorId: null,
number =>
{
seen.Add(number);
return Task.FromResult(CliExitCodes.Ok);
});
var expected = new int?[] { 5 };
CollectionAssert.AreEqual(expected, seen);
Assert.AreEqual(CliExitCodes.Ok, exit);
}
[TestMethod]
public async Task DispatchWriteTargets_NoSelector_DispatchesOnceWithNull()
{
var seen = new List<int?>();
var exit = await Program.DispatchWriteTargetsAsync(
Array.Empty<int>(),
monitorId: null,
number =>
{
seen.Add(number);
return Task.FromResult(CliExitCodes.SelectorMissing);
});
var expected = new int?[] { null };
CollectionAssert.AreEqual(expected, seen);
Assert.AreEqual(CliExitCodes.SelectorMissing, exit);
}
[TestMethod]
public async Task DispatchWriteTargets_MonitorIdWins_DispatchesOnceWithNullNumber()
{
var seen = new List<int?>();
var targets = new[] { 1, 2, 3 };
var exit = await Program.DispatchWriteTargetsAsync(
targets,
monitorId: "MON-X",
number =>
{
seen.Add(number);
return Task.FromResult(CliExitCodes.Ok);
});
// A monitor id wins and collapses the batch to a single id-based dispatch (null number).
var expected = new int?[] { null };
CollectionAssert.AreEqual(expected, seen);
Assert.AreEqual(CliExitCodes.Ok, exit);
}
[TestMethod]
public async Task DispatchWriteTargets_Batch_DispatchesEachAndAggregatesWorst()
{
var seen = new List<int?>();
var codes = new Dictionary<int, int>
{
[1] = CliExitCodes.Ok,
[2] = CliExitCodes.OutOfRange,
[3] = CliExitCodes.HardwareFailure,
};
var targets = new[] { 1, 2, 3 };
var exit = await Program.DispatchWriteTargetsAsync(
targets,
monitorId: null,
number =>
{
seen.Add(number);
return Task.FromResult(codes[number!.Value]);
});
var expected = new int?[] { 1, 2, 3 };
CollectionAssert.AreEqual(expected, seen);
Assert.AreEqual(CliExitCodes.HardwareFailure, exit);
}
[TestMethod]
public async Task DispatchWriteTargets_Batch_UnsupportedDoesNotFailTheBatch()
{
var codes = new Dictionary<int, int>
{
[1] = CliExitCodes.Ok,
[2] = CliExitCodes.UnsupportedFeature,
};
var targets = new[] { 1, 2 };
var exit = await Program.DispatchWriteTargetsAsync(
targets,
monitorId: null,
number => Task.FromResult(codes[number!.Value]));
Assert.AreEqual(CliExitCodes.Ok, exit);
}
[TestMethod]
public async Task DispatchWriteTargets_Batch_AbortsOnProviderUnavailable()
{
var seen = new List<int?>();
var targets = new[] { 1, 2, 3 };
var exit = await Program.DispatchWriteTargetsAsync(
targets,
monitorId: null,
number =>
{
seen.Add(number);
return Task.FromResult(number == 1 ? CliExitCodes.ProviderUnavailable : CliExitCodes.Ok);
});
var expected = new int?[] { 1 };
Assert.AreEqual(CliExitCodes.ProviderUnavailable, exit);
CollectionAssert.AreEqual(expected, seen, "must abort after the first PROVIDER_UNAVAILABLE");
}
// ── WarnIfMonitorNumberIgnored: complete ignored list, formatted invariantly ──────────────
[TestMethod]
public void WarnIfMonitorNumberIgnored_MultipleNumbers_WarningIncludesCompleteList()
{
var output = new RecordingCliOutput();
int[] monitorNumbers = { 1, 2, 3 };
Program.WarnIfMonitorNumberIgnored(output, monitorNumbers, "MON-X");
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], "1,2,3");
// Regression guard: the old single-number message must not appear on its own.
Assert.IsFalse(output.StderrLines[0].EndsWith(" 1 ignored because --monitor-id was also provided", StringComparison.Ordinal));
}
[TestMethod]
public void WarnIfMonitorNumberIgnored_NoMonitorId_NoWarningEmitted()
{
var output = new RecordingCliOutput();
int[] monitorNumbers = { 1, 2, 3 };
Program.WarnIfMonitorNumberIgnored(output, monitorNumbers, null);
Assert.AreEqual(0, output.StderrLines.Count);
}
[TestMethod]
public void WarnIfMonitorNumberIgnored_EmptyList_NoWarningEmitted()
{
var output = new RecordingCliOutput();
Program.WarnIfMonitorNumberIgnored(output, Array.Empty<int>(), "MON-X");
Assert.AreEqual(0, output.StderrLines.Count);
}
[TestMethod]
public async Task Set_MonitorIdWinsOverBatch_WarningIncludesAllIgnoredNumbers()
{
var root = new PowerDisplayRootCommand();
var args = new[] { "set", "-n", "1,2,3", "-i", "MON-X", "--brightness", "50" };
var parseResult = new Parser(root).Parse(args);
var output = new RecordingCliOutput();
// The send delegate is irrelevant here (a null response renders PROVIDER_UNAVAILABLE); the
// warning is emitted synchronously before the dispatch itself.
var dispatcher = new IpcDispatcher((_, _, _) => Task.FromResult<string?>(null), output, TimeSpan.FromSeconds(1));
await Program.DispatchAsync(root, args, parseResult, dispatcher, output, CancellationToken.None);
var warnings = output.StderrLines.Where(l => l.StartsWith("warn:", StringComparison.Ordinal)).ToList();
Assert.AreEqual(1, warnings.Count);
StringAssert.Contains(warnings[0], "1,2,3");
}
// ── get/capabilities reject a comma-separated batch ───────────────────────
[TestMethod]
public Task Get_MultipleMonitors_RejectedAsArgumentError()
=> AssertReadCommandRejectsBatch("get");
[TestMethod]
public Task Capabilities_MultipleMonitors_RejectedAsArgumentError()
=> AssertReadCommandRejectsBatch("capabilities");
private static async Task AssertReadCommandRejectsBatch(string command)
{
var root = new PowerDisplayRootCommand();
var args = new[] { command, "-n", "1,2" };
var parseResult = new Parser(root).Parse(args);
var output = new RecordingCliOutput();
// The dispatcher must never be reached: the batch is rejected CLI-side before any IPC.
var dispatcher = new IpcDispatcher(
(_, _, _) => throw new InvalidOperationException("dispatcher must not be called for a rejected batch"),
output,
TimeSpan.FromSeconds(1));
var exit = await Program.DispatchAsync(root, args, parseResult, dispatcher, output, CancellationToken.None);
Assert.AreEqual(CliExitCodes.ArgumentError, exit);
Assert.AreEqual(1, output.ErrorCount);
Assert.AreEqual(CliErrorCodes.ArgumentError, output.LastError!.Error.Code);
}
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.IO;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Output;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for <see cref="CliErrorLocalizer"/> (the app-Code/MessageId -> localized text mapping) and
/// the <see cref="TextCliOutput.WriteError"/> rendering that consumes it. The app sends only ids +
/// structured data; these pin that the CLI composes the human text from them, and falls back to the
/// app's English message for an unrecognized id.
/// </summary>
[TestClass]
public class CliErrorLocalizerTests
{
[TestMethod]
public void Localize_OutOfRange_SubstitutesValueAndSetting()
{
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
Code = CliErrorCodes.OutOfRange,
MessageId = CliMessageIds.OutOfRange,
Value = "150",
Setting = "brightness",
});
Assert.AreEqual("150 is out of range for brightness", message);
Assert.IsNull(hint);
}
[TestMethod]
public void Localize_Unsupported_UsesSettingName()
{
var (message, _) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.Unsupported,
Setting = "volume",
});
Assert.AreEqual("volume is not supported", message);
}
[TestMethod]
public void Localize_UnknownSetting_ProducesCliGeneratedHint()
{
// The hint's valid-settings list is CLI-known data, generated here (not sent by the app).
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.UnknownSetting,
Value = "foo",
});
Assert.AreEqual("unknown setting foo", message);
Assert.IsNotNull(hint);
StringAssert.Contains(hint, "brightness");
}
[TestMethod]
public void Localize_HardwareFailure_MessageIsFixed_DetailRenderedSeparately()
{
// The driver string travels in Detail (rendered on its own line), not folded into the message.
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.HardwareFailure,
Detail = "DDC write timed out",
});
Assert.AreEqual("hardware write failed", message);
Assert.IsNull(hint);
}
[TestMethod]
public void Localize_UnknownMessageId_FallsBackToAppMessageAndHint()
{
// Version-skew safety: an id the CLI does not recognize degrades to the app's English prose.
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = "an-id-a-future-app-added",
Message = "english fallback",
Hint = "english hint",
});
Assert.AreEqual("english fallback", message);
Assert.AreEqual("english hint", hint);
}
[TestMethod]
public void Localize_EmptyMessageId_FallsBackToAppMessage()
{
// CLI-side errors (parse/validation) already carry a localized Message and no MessageId.
var (message, _) = CliErrorLocalizer.Localize(new CliError
{
Message = "already-localized cli-side message",
});
Assert.AreEqual("already-localized cli-side message", message);
}
[TestMethod]
public void WriteError_OutOfRange_RendersMessageExpectedAndLabels()
{
var stderr = new StringWriter();
var output = new TextCliOutput(new StringWriter(), stderr, quiet: false);
output.WriteError(new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.OutOfRange,
MessageId = CliMessageIds.OutOfRange,
Value = "150",
Setting = "brightness",
ExpectedRange = "[0, 100]",
},
});
var text = stderr.ToString();
StringAssert.Contains(text, "150 is out of range for brightness");
StringAssert.Contains(text, "[0, 100]");
}
[TestMethod]
public void WriteError_HardwareFailure_RendersDetailLine()
{
var stderr = new StringWriter();
var output = new TextCliOutput(new StringWriter(), stderr, quiet: false);
output.WriteError(new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.HardwareFailure,
MessageId = CliMessageIds.HardwareFailure,
Detail = "DDC write timed out",
},
});
var text = stderr.ToString();
StringAssert.Contains(text, "hardware write failed");
StringAssert.Contains(text, "DDC write timed out");
}
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for <see cref="CliPipeClient"/>.
/// </summary>
[TestClass]
public class CliPipeClientTests
{
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(200);
// ── Happy-path: in-proc fake server, trusted verifier ─────────────────────
[TestMethod]
[Timeout(10_000)]
public async Task SendAsync_WithFakeServerAndTrustedVerifier_ReturnsCannedResponse()
{
const string RequestJson = @"{""command"":""list""}";
const string ResponseJson = @"{""monitors"":[]}";
// Start a one-shot in-proc server on the same pipe name
using var serverReady = new SemaphoreSlim(0, 1);
var serverTask = Task.Run(async () =>
{
using var server = new NamedPipeServerStream(
PipeNames.CliServer(),
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
serverReady.Release(); // signal: server is now listening
await server.WaitForConnectionAsync();
// Mirror the server protocol: BOM-less UTF-16 LE (same as CliPipeClient / CliPipeServer).
// Use the shared pipe encoding/buffer so the fake server stays byte-compatible with the client.
using var reader = new StreamReader(server, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
using var writer = new StreamWriter(server, CliPipeProtocol.PipeEncoding, CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
_ = await reader.ReadLineAsync();
// Echo back the canned response regardless of what was sent
await writer.WriteLineAsync(ResponseJson);
});
// Wait until the server is listening before connecting
await serverReady.WaitAsync(TimeSpan.FromSeconds(5));
// The in-proc fake server is not the real sibling PowerToys.PowerDisplay.exe, so the
// real production verifier would reject it. Inject a trusted stub via the internal
// constructor to exercise the round trip without production bypasses.
var client = new CliPipeClient(static _ => true);
var result = await client.SendAsync(RequestJson, ConnectTimeout, CancellationToken.None);
await serverTask; // ensure the server task completes cleanly
Assert.AreEqual(ResponseJson, result);
}
// ── Untrusted server: verifier rejects, no request body is ever sent ─────
[TestMethod]
[Timeout(10_000)]
public async Task SendAsync_WithUntrustedVerifier_ReturnsNullAndSendsNoRequestBody()
{
const string RequestJson = @"{""command"":""list""}";
using var serverReady = new SemaphoreSlim(0, 1);
string? receivedLine = "not-read-yet";
var serverTask = Task.Run(async () =>
{
using var server = new NamedPipeServerStream(
PipeNames.CliServer(),
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
serverReady.Release();
await server.WaitForConnectionAsync();
using var reader = new StreamReader(server, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
// The client must close the connection right after a failed verification and before
// writing anything, so the read reaches end-of-stream (null) instead of returning a line.
receivedLine = await reader.ReadLineAsync();
});
await serverReady.WaitAsync(TimeSpan.FromSeconds(5));
var client = new CliPipeClient(static _ => false);
var result = await client.SendAsync(RequestJson, ConnectTimeout, CancellationToken.None);
await serverTask;
Assert.IsNull(result, "Expected null when the connected server fails identity verification");
Assert.IsNull(receivedLine, "Expected no request body to reach an untrusted server");
}
// ── No-server path: returns null within short timeout ────────────────────
[TestMethod]
[Timeout(5_000)]
public async Task SendAsync_NoServer_ReturnsNullWithinShortTimeout()
{
// There is no server listening on this pipe, so ConnectAsync will throw TimeoutException.
// We use ShortTimeout (200 ms) to keep the test fast.
var client = new CliPipeClient();
var result = await client.SendAsync(@"{""command"":""list""}", ShortTimeout, CancellationToken.None);
Assert.IsNull(result, "Expected null when no pipe server is running");
}
// ── Cancellation propagates ───────────────────────────────────────────────
[TestMethod]
[Timeout(5_000)]
public async Task SendAsync_CancelledToken_ThrowsOperationCanceledException()
{
using var cts = new CancellationTokenSource();
cts.Cancel(); // pre-cancelled
var client = new CliPipeClient();
// Assert.ThrowsExceptionAsync<T> matches the exact type, so TaskCanceledException
// (which derives from OperationCanceledException) would fail it. Use a manual
// try/catch so any subclass of OperationCanceledException is accepted.
try
{
await client.SendAsync(@"{""command"":""list""}", ConnectTimeout, cts.Token);
Assert.Fail("Expected the operation to be cancelled.");
}
catch (OperationCanceledException)
{
// expected (TaskCanceledException derives from OperationCanceledException)
}
}
// ── PipeServerIdentity: exact sibling-path comparison ─────────────────────
[TestMethod]
public void PathsMatch_ExactPathDifferentCasing_ReturnsTrue()
{
const string Expected = @"C:\Program Files\PowerToys\PowerToys.PowerDisplay.exe";
const string Actual = @"c:\program files\powertoys\POWERTOYS.POWERDISPLAY.EXE";
Assert.IsTrue(PipeServerIdentity.PathsMatch(Actual, Expected));
}
[TestMethod]
public void PathsMatch_EquivalentFullPathsContainingDotSegments_ReturnsTrue()
{
const string Expected = @"C:\Program Files\PowerToys\PowerToys.PowerDisplay.exe";
const string Actual = @"C:\Program Files\PowerToys\.\Subfolder\..\PowerToys.PowerDisplay.exe";
Assert.IsTrue(PipeServerIdentity.PathsMatch(Actual, Expected));
}
[TestMethod]
public void PathsMatch_DifferentDirectory_ReturnsFalse()
{
const string Expected = @"C:\Program Files\PowerToys\PowerToys.PowerDisplay.exe";
const string Actual = @"C:\Some\Other\Place\PowerToys.PowerDisplay.exe";
Assert.IsFalse(PipeServerIdentity.PathsMatch(Actual, Expected));
}
[TestMethod]
public void PathsMatch_DifferentFileName_ReturnsFalse()
{
const string Expected = @"C:\Program Files\PowerToys\PowerToys.PowerDisplay.exe";
const string Actual = @"C:\Program Files\PowerToys\PowerToys.NotPowerDisplay.exe";
Assert.IsFalse(PipeServerIdentity.PathsMatch(Actual, Expected));
}
// ── PipeServerIdentity: real GetNamedPipeServerProcessId + QueryFullProcessImageNameW round trip ──
[TestMethod]
[Timeout(10_000)]
public async Task IsTrustedServer_SelfConnectedPipeWithMatchingExpectedPath_ReturnsTrue()
{
string selfPipeName = $"PowerDisplay_Cli_UnitTests_{Guid.NewGuid():N}";
string currentProcessPath = Environment.ProcessPath
?? throw new InvalidOperationException("Environment.ProcessPath is unexpectedly null for the current test host process.");
using var server = new NamedPipeServerStream(selfPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
using var client = new NamedPipeClientStream(".", selfPipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
var acceptTask = server.WaitForConnectionAsync();
await client.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, CancellationToken.None);
await acceptTask;
// The pipe server here is the current test-host process itself, so the expected path is
// this process's own image path when verifying the client-side connected stream.
Assert.IsTrue(PipeServerIdentity.IsTrustedServer(client, currentProcessPath));
}
[TestMethod]
[Timeout(10_000)]
public async Task IsTrustedServer_SelfConnectedPipeWithMismatchedExpectedPath_ReturnsFalse()
{
string selfPipeName = $"PowerDisplay_Cli_UnitTests_{Guid.NewGuid():N}";
using var server = new NamedPipeServerStream(selfPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
using var client = new NamedPipeClientStream(".", selfPipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
var acceptTask = server.WaitForConnectionAsync();
await client.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, CancellationToken.None);
await acceptTask;
Assert.IsFalse(PipeServerIdentity.IsTrustedServer(client, @"C:\definitely\not\the\real\process.exe"));
}
}

View File

@@ -0,0 +1,241 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests the IPC dispatch path: provider-unavailable (null response) → exit 10,
/// success response → rendered and exit 0, and error response → rendered and
/// correct exit code.
/// </summary>
[TestClass]
public class IpcDispatchTests
{
private static readonly TimeSpan AnyTimeout = TimeSpan.FromSeconds(30);
// ── helpers ──────────────────────────────────────────────────────────────
private static IpcDispatcher MakeDispatcher(string? stubResponse, RecordingCliOutput output)
{
Task<string?> StubSend(string requestJson, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(stubResponse);
return new IpcDispatcher(StubSend, output, AnyTimeout);
}
private static string SerializeSuccess<T>(T obj, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo)
=> JsonSerializer.Serialize(obj, typeInfo);
private static string SerializeError(CliErrorResult err)
=> JsonSerializer.Serialize(err, ContractsJsonContext.Default.CliErrorResult);
// ── ProviderUnavailable (null) ────────────────────────────────────────────
[TestMethod]
public async Task When_provider_unavailable_list_exits_10()
{
var output = new RecordingCliOutput();
var dispatcher = MakeDispatcher(null, output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.ProviderUnavailable, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.ProviderUnavailable);
StringAssert.Contains(output.StderrLines[0], "10");
}
// ── Success responses rendered, exit 0 ───────────────────────────────────
[TestMethod]
public async Task Success_set_renders_result_exits_0()
{
var output = new RecordingCliOutput();
var responseJson = SerializeSuccess(
new CliSetResult { Setting = "brightness", Monitor = new CliMonitorRef { Number = 1, Id = "x", Name = "N" }, AfterDisplay = "80%" },
ContractsJsonContext.Default.CliSetResult);
var dispatcher = MakeDispatcher(responseJson, output);
var inputs = new SetCommandInputs { Brightness = 80 };
var exit = await dispatcher.SendSetAsync(CliRequestBuilder.BuildSet(inputs), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit);
Assert.AreEqual(1, output.StdoutLines.Count);
StringAssert.Contains(output.StdoutLines[0], "brightness");
}
// ── Error responses rendered, correct exit code ───────────────────────────
[TestMethod]
public async Task Error_response_renders_error_and_returns_its_exit_code()
{
var output = new RecordingCliOutput();
var errorResponse = new CliErrorResult
{
Command = "list",
Error = new CliError
{
Code = CliErrorCodes.MonitorNotFound,
Message = "Monitor not found.",
},
};
var responseJson = SerializeError(errorResponse);
var dispatcher = MakeDispatcher(responseJson, output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.MonitorNotFound, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.MonitorNotFound);
// An error envelope (isError=true) routes through the error renderer (stderr) only and must
// never leak to the success path (stdout).
Assert.AreEqual(0, output.StdoutLines.Count, "error envelope must not render via the success path");
}
// ── apply-profile always exits 0 (best-effort) ───────────────────────────
[TestMethod]
public async Task ApplyProfile_success_exits_0()
{
var output = new RecordingCliOutput();
var responseJson = SerializeSuccess(
new CliApplyProfileResult { Profile = "Work" },
ContractsJsonContext.Default.CliApplyProfileResult);
var dispatcher = MakeDispatcher(responseJson, output);
var exit = await dispatcher.SendApplyProfileAsync(CliRequestBuilder.BuildApplyProfile(42), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit, "apply-profile is best-effort and always exits 0 once the profile exists");
// apply-profile is a success envelope (isError=false): it must route through the success
// renderer (stdout) and never WriteError.
Assert.AreEqual(1, output.StdoutLines.Count, "rendered via the success path");
Assert.AreEqual(0, output.StderrLines.Count, "must not go through WriteError");
}
// ── schema-mismatch / undeserializable response → InternalError (9) ────────
[TestMethod]
public async Task Malformed_json_response_exits_internal_error()
{
var output = new RecordingCliOutput();
var dispatcher = MakeDispatcher("{ this is not valid json", output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.InternalError, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.InternalError);
}
[TestMethod]
public async Task Wrong_shape_response_exits_internal_error()
{
// Valid JSON with isError:false, but the success payload cannot deserialize as the expected
// type (monitors is a string, not an array) — the version-skew fallback path.
var output = new RecordingCliOutput();
var dispatcher = MakeDispatcher("{\"isError\":false,\"monitors\":\"oops\"}", output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.InternalError, exit);
}
// ── CliRequestBuilder round-trips ────────────────────────────────────────
[TestMethod]
public void BuildSet_Brightness_MapsCorrectly()
{
var inputs = new SetCommandInputs { Brightness = 75, MonitorNumber = 2 };
var envelope = CliRequestBuilder.BuildSet(inputs);
Assert.AreEqual(CliCommandNames.Set, envelope.Command);
Assert.IsNotNull(envelope.Set);
Assert.AreEqual("brightness", envelope.Set!.Setting);
Assert.AreEqual("75", envelope.Set.RawValue);
Assert.AreEqual(2, envelope.Set.MonitorNumber);
}
[TestMethod]
public void BuildSet_PowerState_MapsCorrectly()
{
var inputs = new SetCommandInputs { PowerState = "Standby", ConfirmPowerOff = true };
var envelope = CliRequestBuilder.BuildSet(inputs);
Assert.AreEqual("power-state", envelope.Set!.Setting);
Assert.AreEqual("Standby", envelope.Set.RawValue);
Assert.IsTrue(envelope.Set.ConfirmPowerOff);
}
[TestMethod]
public void BuildSet_NoSetting_Throws()
{
var inputs = new SetCommandInputs();
Assert.ThrowsException<InvalidOperationException>(() => CliRequestBuilder.BuildSet(inputs));
}
[TestMethod]
public void BuildGet_Maps_MonitorSelectors_And_Filter()
{
var envelope = CliRequestBuilder.BuildGet(3, "myId", "brightness");
Assert.AreEqual(CliCommandNames.Get, envelope.Command);
Assert.AreEqual(3, envelope.Get!.MonitorNumber);
Assert.AreEqual("myId", envelope.Get.MonitorId);
Assert.AreEqual("brightness", envelope.Get.SettingFilter);
}
[TestMethod]
public void BuildApplyProfile_Maps_ProfileId()
{
var envelope = CliRequestBuilder.BuildApplyProfile(7);
Assert.AreEqual(CliCommandNames.ApplyProfile, envelope.Command);
Assert.AreEqual(7, envelope.ApplyProfile!.ProfileId);
}
// ── BuildAdjust round-trips ──────────────────────────────────────────────
[TestMethod]
public void BuildAdjust_Up_Brightness_MapsCommandSettingAndStep()
{
var inputs = new AdjustCommandInputs { Brightness = true, Step = 10, MonitorNumber = 2 };
var envelope = CliRequestBuilder.BuildAdjust(CliCommandNames.Up, inputs);
Assert.AreEqual(CliCommandNames.Up, envelope.Command);
Assert.IsNotNull(envelope.Adjust);
Assert.AreEqual("brightness", envelope.Adjust!.Setting);
Assert.AreEqual(10, envelope.Adjust.Step);
Assert.AreEqual(2, envelope.Adjust.MonitorNumber);
}
[TestMethod]
public void BuildAdjust_Down_Contrast_NullStep()
{
var inputs = new AdjustCommandInputs { Contrast = true, Step = null };
var envelope = CliRequestBuilder.BuildAdjust(CliCommandNames.Down, inputs);
Assert.AreEqual(CliCommandNames.Down, envelope.Command);
Assert.AreEqual("contrast", envelope.Adjust!.Setting);
Assert.IsNull(envelope.Adjust.Step);
}
[TestMethod]
public void BuildAdjust_NoSetting_Throws()
{
Assert.ThrowsException<InvalidOperationException>(
() => CliRequestBuilder.BuildAdjust(CliCommandNames.Up, new AdjustCommandInputs()));
}
// ── SendAdjustAsync renders via the set renderer, exits 0 ─────────────────
[TestMethod]
public async Task Success_adjust_renders_result_exits_0()
{
var output = new RecordingCliOutput();
var responseJson = SerializeSuccess(
new CliSetResult { Command = "up", Setting = "brightness", Monitor = new CliMonitorRef { Number = 1, Id = "x", Name = "N" }, AfterDisplay = "60%" },
ContractsJsonContext.Default.CliSetResult);
var dispatcher = MakeDispatcher(responseJson, output);
var inputs = new AdjustCommandInputs { Brightness = true, Step = 10 };
var exit = await dispatcher.SendAdjustAsync(CliRequestBuilder.BuildAdjust(CliCommandNames.Up, inputs), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit);
Assert.AreEqual(1, output.StdoutLines.Count);
StringAssert.Contains(output.StdoutLines[0], "brightness");
}
}

View File

@@ -0,0 +1,37 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerDisplay.Cli.UnitTests</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Cli.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.CodeDom">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerDisplay.Cli\PowerDisplay.Cli.csproj" />
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Options;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class ProgramTokenTests
{
private static ParseResult Parse(params string[] args)
=> new Parser(new PowerDisplayRootCommand()).Parse(args);
[TestMethod]
public void HelpFlag_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("--help")));
[TestMethod]
public void HelpUnderSubcommand_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("get", "--help")));
[TestMethod]
public void HelpValueOfOption_IsNotTreatedAsHelp()
=> Assert.IsFalse(Program.HasHelpToken(Parse("set", "-i", "-h", "--brightness", "50")));
[TestMethod]
public void HelpUnderApplyProfile_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("apply-profile", "--help")));
[TestMethod]
public void ApplyProfileWithId_IsNotHelp()
=> Assert.IsFalse(Program.HasHelpToken(Parse("apply-profile", "5")));
[TestMethod]
public void VersionFlag_IsDetected()
=> Assert.IsTrue(Program.HasVersionToken(Parse("--version")));
[TestMethod]
public void VersionFlag_DetectedAlongsideValidOptions()
=> Assert.IsTrue(Program.HasVersionToken(Parse("set", "-n", "1", "--version")));
[TestMethod]
public void VersionValueOfOption_IsNotTreatedAsVersion()
=> Assert.IsFalse(Program.HasVersionToken(Parse("set", "-i", "--version", "--brightness", "50")));
[TestMethod]
public void IsVersionRequest_BareVersion_True()
=> Assert.IsTrue(Program.IsVersionRequest(Parse("--version")));
[TestMethod]
public void IsVersionRequest_VersionAfterSubcommand_False()
=> Assert.IsFalse(Program.IsVersionRequest(Parse("set", "-n", "1", "--version")));
[TestMethod]
public void IsVersionRequest_VersionUnderApplyProfile_True()
{
// `apply-profile <id>` is an int argument, so it can no longer greedily bind "--version" as
// a profile id. The token is detected by HasVersionToken, and IsVersionRequest now allows
// both RootCommand and apply-profile, so this returns True (version is shown for apply-profile --version).
Assert.IsTrue(Program.IsVersionRequest(Parse("apply-profile", "--version")));
}
[TestMethod]
public void ApplyProfileWithId_IsNotVersion()
=> Assert.IsFalse(Program.IsVersionRequest(Parse("apply-profile", "5")));
[TestMethod]
public void BuildParseErrorResult_CollapsesMultipleMessagesIntoOneEnvelope()
{
// System.CommandLine can report several errors for one bad invocation; they must be
// collapsed into a single envelope so consumers receive one parseable object.
var messages = new[] { "first problem", "second problem" };
var result = Program.BuildParseErrorResult("set", messages);
Assert.AreEqual("set", result.Command);
Assert.AreEqual(CliErrorCodes.ArgumentError, result.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, result.Error.ExitCode);
StringAssert.Contains(result.Error.Message, "first problem");
StringAssert.Contains(result.Error.Message, "second problem");
}
[TestMethod]
public void BuildParseErrorResult_EmptyMessages_FallsBackToGenericMessage()
{
var blanks = new[] { string.Empty, " " };
var result = Program.BuildParseErrorResult("get", blanks);
Assert.AreEqual("invalid arguments", result.Error.Message);
}
[TestMethod]
public void Step_Negative_ProducesParseError()
{
var parsed = Parse("up", "--brightness", "--step", "-5");
Assert.IsTrue(parsed.Errors.Count > 0, "a negative --step must be a parse error");
}
[TestMethod]
public void Step_Zero_IsAccepted()
{
var parsed = Parse("up", "--brightness", "--step", "0");
Assert.AreEqual(0, parsed.Errors.Count, "--step 0 is a valid no-op and must not error");
}
[TestMethod]
public void Up_BrightnessFlag_ParsesWithoutValue()
{
var parsed = Parse("up", "--brightness");
Assert.AreEqual(0, parsed.Errors.Count);
Assert.IsTrue(parsed.GetValueForOption(CliOptions.BrightnessFlag));
}
[TestMethod]
public void Up_BrightnessFlag_RejectsAttachedValue()
{
// The up/down setting flags are pure presence flags (ArgumentArity.Zero). A following
// bareword like "false" must NOT be swallowed as the flag's value (which would silently make
// the flag false and yield a misleading "no setting specified"); it is an unrecognized token.
var parsed = Parse("up", "--brightness", "false");
Assert.IsTrue(parsed.Errors.Count > 0, "an attached value on a no-value flag must be a parse error");
}
[TestMethod]
public void Quiet_DoesNotSwallowFollowingArgument()
{
// Regression: --quiet is a global Option<bool>. With ArgumentArity.Zero it must NOT swallow a
// following bareword that parses as a bool, so `apply-profile --quiet 1` binds "1" as the
// profile id (not as --quiet's value, which would leave apply-profile with no argument).
var parsed = Parse("apply-profile", "--quiet", "1");
Assert.AreEqual(0, parsed.Errors.Count, "--quiet must not consume the profile id");
Assert.AreEqual(1, parsed.GetValueForArgument(CliOptions.ProfileId));
Assert.IsTrue(parsed.GetValueForOption(CliOptions.Quiet), "a bare --quiet resolves to true");
}
[TestMethod]
public void ConfirmPowerOff_ResolvesToTrueWhenPresent()
{
// --confirm-power-off is a pure presence flag (ArgumentArity.Zero): present -> true, and it
// does not swallow the following power-state value.
var parsed = Parse("set", "--power-state", "0x04", "--confirm-power-off");
Assert.AreEqual(0, parsed.Errors.Count);
Assert.IsTrue(parsed.GetValueForOption(CliOptions.ConfirmPowerOff));
Assert.AreEqual("0x04", parsed.GetValueForOption(CliOptions.PowerState));
}
[TestMethod]
public void ConnectTimeout_IsStrictlyShorterThanOperationTimeout()
{
// Guards the connect-timeout fix: the pipe-connect bound must stay strictly below the overall
// deadline, or a not-running app is misreported as TIMEOUT (exit 8) after the full deadline
// instead of a fast PROVIDER_UNAVAILABLE (exit 10). See Program.ConnectTimeout / OperationTimeout.
Assert.IsTrue(
Program.ConnectTimeout < Program.OperationTimeout,
$"ConnectTimeout ({Program.ConnectTimeout}) must be < OperationTimeout ({Program.OperationTimeout})");
}
[TestMethod]
public void ApplyProfile_ParsesIntegerId()
{
var parse = Parse("apply-profile", "5");
Assert.AreEqual(0, parse.Errors.Count);
Assert.AreEqual(5, parse.GetValueForArgument(CliOptions.ProfileId));
}
[TestMethod]
public void ApplyProfile_NonInteger_IsParseError()
{
var parse = Parse("apply-profile", "Gaming");
Assert.IsTrue(parse.Errors.Count > 0);
}
[TestMethod]
public void ApplyProfile_HelpToken_IsRecognizedAsHelp()
{
var parse = Parse("apply-profile", "--help");
Assert.IsTrue(Program.HasHelpToken(parse));
}
[TestMethod]
public void ApplyProfile_VersionToken_IsRecognizedAsVersion()
{
var parse = Parse("apply-profile", "--version");
Assert.IsTrue(Program.IsVersionRequest(parse));
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using PowerDisplay.Cli.Output;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Shared <see cref="ICliOutput"/> test double. Records each rendered result as a tagged stdout line
/// and each warning/error as a tagged stderr line (so dispatch tests can assert which renderer ran),
/// and additionally tracks the error count and last error (so batch tests can assert aggregation).
/// </summary>
internal sealed class RecordingCliOutput : ICliOutput
{
private readonly List<string> stdoutLines = new();
private readonly List<string> stderrLines = new();
public IReadOnlyList<string> StdoutLines => this.stdoutLines;
public IReadOnlyList<string> StderrLines => this.stderrLines;
public int ErrorCount { get; private set; }
public CliErrorResult? LastError { get; private set; }
public void WriteListResult(CliListResult r) => this.stdoutLines.Add("list:" + r.Command);
public void WriteSetResult(CliSetResult r) => this.stdoutLines.Add("set:" + r.Setting);
public void WriteGetResult(CliGetResult r) => this.stdoutLines.Add("get");
public void WriteCapabilitiesResult(CliCapabilitiesResult r) => this.stdoutLines.Add("capabilities");
public void WriteProfileListResult(CliProfileListResult r) => this.stdoutLines.Add("profiles");
public void WriteApplyProfileResult(CliApplyProfileResult r) => this.stdoutLines.Add("apply-profile:" + r.Profile);
public void WriteError(CliErrorResult r)
{
this.ErrorCount++;
this.LastError = r;
this.stderrLines.Add("error:" + r.Error.Code + ":" + r.Error.ExitCode);
}
public void WriteWarning(string message) => this.stderrLines.Add("warn:" + message);
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Options;
using PowerDisplay.Cli.Properties;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class ResourcesTests
{
private const string ExecutableName = "PowerToys.PowerDisplay.Cli.exe";
[TestMethod]
public void CommandHints_ReferenceShippedExecutable()
{
StringAssert.Contains(Resources.Hint_UseSetForAbsolute, ExecutableName);
StringAssert.Contains(Resources.Hint_UseHexVcp, ExecutableName);
StringAssert.Contains(Resources.Hint_RunList, ExecutableName);
StringAssert.Contains(Resources.Hint_SelectorMissing, ExecutableName);
StringAssert.Contains(Resources.Hint_RunProfiles, ExecutableName);
}
[TestMethod]
public void OptionDescriptions_ReferenceShippedExecutable()
{
StringAssert.Contains(CliOptions.MonitorNumber.Description, ExecutableName);
StringAssert.Contains(CliOptions.ColorTemperature.Description, ExecutableName);
StringAssert.Contains(CliOptions.InputSource.Description, ExecutableName);
StringAssert.Contains(CliOptions.PowerState.Description, ExecutableName);
StringAssert.Contains(CliOptions.ProfileId.Description, ExecutableName);
}
[TestMethod]
public void SafeFormat_PlaceholderIndexOutOfRange_DoesNotThrow_ReturnsTemplate()
{
// A translation that renumbers a placeholder ({0} -> {1}) leaves an index with no argument;
// the guarantee is "degrade to the template, never throw".
Assert.AreEqual("value {1}", Resources.SafeFormat("value {1}", "x"));
}
[TestMethod]
public void SafeFormat_UnescapedBrace_DoesNotThrow_ReturnsTemplate()
{
// A translation with an unescaped brace is also a malformed format string.
Assert.AreEqual("oops {", Resources.SafeFormat("oops {", "x"));
}
[TestMethod]
public void SafeFormat_WellFormedTemplate_SubstitutesArgument()
{
// The success path must actually substitute — without this, a regression to `return template;`
// would silently drop every {0}/{1} from localized messages while the malformed-template tests
// above stayed green (a malformed template returns unchanged either way).
Assert.AreEqual("value x", Resources.SafeFormat("value {0}", "x"));
Assert.AreEqual("a then b", Resources.SafeFormat("{0} then {1}", "a", "b"));
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Commands;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class SetCommandInputsTests
{
// The count drives the "exactly one setting" validation in Program: 0 -> NoSetting error,
// 1 -> proceed, >1 -> OnlyOneSetting error. Exercise the 0/1/2 thresholds in one place.
[TestMethod]
public void CountSelectedSettings_CountsAcrossThresholds()
{
Assert.AreEqual(0, SetCommand.CountSelectedSettings(new SetCommandInputs()));
Assert.AreEqual(1, SetCommand.CountSelectedSettings(new SetCommandInputs { Brightness = 50 }));
Assert.AreEqual(2, SetCommand.CountSelectedSettings(new SetCommandInputs { Brightness = 50, Contrast = 70 }));
}
[TestMethod]
public void CountSelectedSettings_AllSeven()
{
var inputs = new SetCommandInputs
{
Brightness = 0,
Contrast = 0,
Volume = 0,
ColorTemperature = "x",
InputSource = "x",
PowerState = "x",
Orientation = "x",
};
Assert.AreEqual(7, SetCommand.CountSelectedSettings(inputs));
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Output;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for <see cref="TextCliOutput"/>'s human-readable table rendering: the list/profile tables
/// must preserve the full monitor/profile name exactly (no UTF-16 code-unit truncation or fixed-width
/// padding) and must render as a delimiter-separated line, since this is human output rather than a
/// machine (JSON) contract.
/// </summary>
[TestClass]
public class TextCliOutputTests
{
// A name containing a non-BMP emoji (surrogate pair), a combining mark, and CJK characters — all
// of which a UTF-16 code-unit Substring/PadRight would mangle or split.
private const string NonBmpName = "🖥Monitor-测试-e\u0301xtra-long-name-that-would-have-been-truncated";
[TestMethod]
public void WriteListResult_LongNonBmpName_PreservedExactlyWithDelimiterFormat()
{
var stdout = new StringWriter();
var output = new TextCliOutput(stdout, new StringWriter());
output.WriteListResult(new CliListResult
{
Monitors = new List<CliMonitorRef>
{
new() { Number = 1, Id = "MON-1", Name = NonBmpName, Method = "DDC/CI" },
},
});
var lines = stdout.ToString().Split('\n').Select(l => l.TrimEnd('\r')).ToList();
// The row must contain the full, untruncated name — no "…" ellipsis marker.
var row = lines.Single(l => l.Contains("MON-1"));
StringAssert.Contains(row, NonBmpName);
Assert.IsFalse(row.Contains('…'), "the row must not contain a truncation ellipsis");
// Delimiter-separated, not fixed-width padded columns.
var expectedRow = $"1 | {NonBmpName} | DDC/CI | MON-1";
Assert.AreEqual(expectedRow, row);
}
[TestMethod]
public void WriteProfileListResult_LongNonBmpName_PreservedExactlyWithDelimiterFormat()
{
var stdout = new StringWriter();
var output = new TextCliOutput(stdout, new StringWriter());
output.WriteProfileListResult(new CliProfileListResult
{
Profiles = new List<CliProfileInfo>
{
new() { Id = 7, Name = NonBmpName, MonitorCount = 2, LastModified = "2025-01-01T00:00:00Z" },
},
});
var lines = stdout.ToString().Split('\n').Select(l => l.TrimEnd('\r')).ToList();
var row = lines.Single(l => l.Contains("2025-01-01T00:00:00Z"));
StringAssert.Contains(row, NonBmpName);
Assert.IsFalse(row.Contains('…'), "the row must not contain a truncation ellipsis");
var expectedRow = $"7 | {NonBmpName} | 2 | 2025-01-01T00:00:00Z";
Assert.AreEqual(expectedRow, row);
}
[TestMethod]
public void WriteApplyProfileResult_UsesBestEffortText_NotAppliedClaim()
{
var stdout = new StringWriter();
var output = new TextCliOutput(stdout, new StringWriter());
output.WriteApplyProfileResult(new CliApplyProfileResult { ProfileId = 3, Profile = "Office" });
var text = stdout.ToString().Trim();
Assert.AreEqual("Processed profile 'Office' (best effort).", text);
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
namespace PowerDisplay.Cli.Commands;
public static class AdjustCommand
{
/// <summary>
/// Counts how many continuous-setting flags are set in <paramref name="inputs"/>.
/// Exactly one must be true for a valid <c>up</c>/<c>down</c> invocation.
/// </summary>
public static int CountSelectedSettings(AdjustCommandInputs inputs)
{
// Mirror SetCommand.CountSelectedSettings: list the candidate flags, then Count the selected.
bool[] flags = [inputs.Brightness, inputs.Contrast, inputs.Volume];
return flags.Count(f => f);
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Inputs collected from the parsed <c>up</c>/<c>down</c> subcommand. Exactly one of the three
/// continuous-setting flags must be true. <see cref="Step"/> is null when <c>--step</c> is omitted.
/// </summary>
public sealed class AdjustCommandInputs
{
public int? MonitorNumber { get; init; }
public string? MonitorId { get; init; }
public bool Brightness { get; init; }
public bool Contrast { get; init; }
public bool Volume { get; init; }
public int? Step { get; init; }
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.CommandLine;
using PowerDisplay.Cli.Options;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Builds the <c>powerdisplay</c> root command and its subcommands. <see cref="Program"/>
/// dispatches on <c>parseResult.CommandResult.Command.Name</c> against the
/// <see cref="CliCommandNames"/> constants.
/// </summary>
// 'partial' is required by the CsWinRT analyzer (CsWinRT1028) for AOT/WinRT-ABI compatibility,
// even though there is only one declaration.
public sealed partial class PowerDisplayRootCommand : RootCommand
{
public PowerDisplayRootCommand()
: base("PowerToys PowerDisplay - control monitor settings from the command line.")
{
AddGlobalOption(CliOptions.Quiet);
AddCommand(BuildList());
AddCommand(BuildCapabilities());
AddCommand(BuildGet());
AddCommand(BuildSet());
AddCommand(BuildProfiles());
AddCommand(BuildApplyProfile());
AddCommand(BuildUp());
AddCommand(BuildDown());
}
private static Command BuildList()
{
return new Command(CliCommandNames.List, "Discover attached monitors and print their number, stable id, name, and transport.");
}
private static Command BuildCapabilities()
{
var cmd = new Command(CliCommandNames.Capabilities, "Print the VCP capabilities advertised by the monitor. Use --setting to restrict to one discrete setting (color-temperature, input-source, power-state).");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.SettingFilter);
return cmd;
}
private static Command BuildGet()
{
var cmd = new Command(CliCommandNames.Get, "Read the current value of one or all settings for a monitor.");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.SettingFilter);
return cmd;
}
private static Command BuildSet()
{
var cmd = new Command(CliCommandNames.Set, "Apply a single setting to a monitor. Exactly one --<setting> flag must be provided.");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.Brightness);
cmd.AddOption(CliOptions.Contrast);
cmd.AddOption(CliOptions.Volume);
cmd.AddOption(CliOptions.ColorTemperature);
cmd.AddOption(CliOptions.InputSource);
cmd.AddOption(CliOptions.PowerState);
cmd.AddOption(CliOptions.Orientation);
cmd.AddOption(CliOptions.ConfirmPowerOff);
return cmd;
}
private static Command BuildProfiles()
{
return new Command(CliCommandNames.Profiles, "List the saved PowerDisplay profiles (name, monitor count, last modified).");
}
private static Command BuildApplyProfile()
{
var cmd = new Command(CliCommandNames.ApplyProfile, "Apply a saved profile's per-monitor settings to the connected monitors.");
cmd.AddArgument(CliOptions.ProfileId);
return cmd;
}
private static Command BuildUp()
{
var cmd = new Command(CliCommandNames.Up, "Raise a continuous setting (brightness, contrast, or volume) relative to its current value. Exactly one --<setting> flag must be provided.");
AddAdjustOptions(cmd);
return cmd;
}
private static Command BuildDown()
{
var cmd = new Command(CliCommandNames.Down, "Lower a continuous setting (brightness, contrast, or volume) relative to its current value. Exactly one --<setting> flag must be provided.");
AddAdjustOptions(cmd);
return cmd;
}
private static void AddAdjustOptions(Command cmd)
{
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.BrightnessFlag);
cmd.AddOption(CliOptions.ContrastFlag);
cmd.AddOption(CliOptions.VolumeFlag);
cmd.AddOption(CliOptions.Step);
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
namespace PowerDisplay.Cli.Commands;
public static class SetCommand
{
/// <summary>
/// Counts how many settings are specified in <paramref name="inputs"/>.
/// Exactly one must be non-null for a valid <c>set</c> invocation.
/// </summary>
public static int CountSelectedSettings(SetCommandInputs inputs)
{
// A continuous int? of 0 still boxes to a non-null object, so zero-valued
// settings are counted just like the discrete string settings.
object?[] settings =
[
inputs.Brightness,
inputs.Contrast,
inputs.Volume,
inputs.ColorTemperature,
inputs.InputSource,
inputs.PowerState,
inputs.Orientation,
];
return settings.Count(s => s is not null);
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Inputs collected from the parsed <c>set</c> subcommand. Exactly one of the
/// setting fields must be non-null.
/// </summary>
public sealed class SetCommandInputs
{
public int? MonitorNumber { get; init; }
public string? MonitorId { get; init; }
public int? Brightness { get; init; }
public int? Contrast { get; init; }
public int? Volume { get; init; }
public string? ColorTemperature { get; init; }
public string? InputSource { get; init; }
public string? PowerState { get; init; }
public string? Orientation { get; init; }
public bool ConfirmPowerOff { get; init; }
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// CLI-side named-pipe client that connects to the running PowerDisplay app, sends one request
/// line, reads one response line, and returns <see langword="null"/> on connect failure or timeout.
/// <para>
/// <b>Protocol:</b> BOM-less UTF-16 LE encoding, <c>'\n'</c>-delimited lines, one request → one response.
/// Mirrors the app-side <c>CliPipeServer</c> in <c>PowerDisplay.Ipc/CliPipeServer.cs</c>.
/// </para>
/// </summary>
public sealed class CliPipeClient
{
private readonly Func<NamedPipeClientStream, bool> _verifyServer;
/// <summary>
/// Creates a client that authenticates the connected server via <see cref="PipeServerIdentity.IsTrustedServer(PipeStream)"/>.
/// </summary>
public CliPipeClient()
: this(PipeServerIdentity.IsTrustedServer)
{
}
/// <summary>
/// Test-only constructor that injects a stand-in verifier so unit tests can accept or reject
/// an in-process fake server without bypassing verification in production code.
/// </summary>
/// <param name="verifyServer">Returns <see langword="true"/> when the connected pipe's server process is trusted.</param>
internal CliPipeClient(Func<NamedPipeClientStream, bool> verifyServer)
{
_verifyServer = verifyServer ?? throw new ArgumentNullException(nameof(verifyServer));
}
/// <summary>
/// Connects to the PowerDisplay named-pipe server, sends <paramref name="requestJson"/>,
/// and returns the response JSON line.
/// </summary>
/// <param name="requestJson">The JSON-encoded request to send.</param>
/// <param name="connectTimeout">How long to wait for the pipe server to accept the connection.</param>
/// <param name="ct">Cancellation token; <see cref="OperationCanceledException"/> propagates to the caller.</param>
/// <returns>
/// The response JSON line on success; <see langword="null"/> when the app is not running,
/// the pipe is unavailable, the connection timed out, or the connected server fails identity
/// verification (i.e. it is not the sibling <c>PowerToys.PowerDisplay.exe</c>).
/// </returns>
public async Task<string?> SendAsync(string requestJson, TimeSpan connectTimeout, CancellationToken ct)
{
try
{
using var client = new NamedPipeClientStream(".", PipeNames.CliServer(), PipeDirection.InOut, PipeOptions.Asynchronous);
await client.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
// Authenticate the connected server before any request bytes are written, so an
// untrusted process never receives the request payload.
if (!_verifyServer(client))
{
return null;
}
using var writer = new StreamWriter(client, CliPipeProtocol.PipeEncoding, CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
using var reader = new StreamReader(client, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
await writer.WriteLineAsync(requestJson.AsMemory(), ct);
return await reader.ReadLineAsync(ct);
}
catch (TimeoutException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
// OperationCanceledException is intentionally NOT caught here — it propagates to the
// caller, which treats Ctrl+C / timeout-token cancellation as user cancellation.
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using PowerDisplay.Cli.Commands;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// Maps parsed CLI arguments into a <see cref="CliRequestEnvelope"/> ready for IPC serialization.
/// One static factory method per command. Syntactic validation (exactly one setting, valid setting
/// name) is intentionally NOT performed here — it lives in <see cref="Program"/> before this
/// builder is called.
/// </summary>
public static class CliRequestBuilder
{
/// <summary>Builds a <c>list</c> request envelope.</summary>
public static CliRequestEnvelope BuildList() => new()
{
Command = CliCommandNames.List,
};
/// <summary>Builds a <c>get</c> request envelope.</summary>
public static CliRequestEnvelope BuildGet(int? monitorNumber, string? monitorId, string? settingFilter) => new()
{
Command = CliCommandNames.Get,
Get = new GetRequest
{
MonitorNumber = monitorNumber,
MonitorId = monitorId,
SettingFilter = settingFilter,
},
};
/// <summary>Builds a <c>set</c> request envelope from the already-validated inputs.
/// Exactly one setting field in <paramref name="inputs"/> must be non-null.</summary>
public static CliRequestEnvelope BuildSet(SetCommandInputs inputs)
{
// Derive the canonical setting name and raw value from the first non-null field.
var (settingName, rawValue) = inputs switch
{
{ Brightness: { } v } => (CliSettingNames.Brightness, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ Contrast: { } v } => (CliSettingNames.Contrast, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ Volume: { } v } => (CliSettingNames.Volume, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ ColorTemperature: { } v } => (CliSettingNames.ColorTemperature, v),
{ InputSource: { } v } => (CliSettingNames.InputSource, v),
{ PowerState: { } v } => (CliSettingNames.PowerState, v),
{ Orientation: { } v } => (CliSettingNames.Orientation, v),
_ => throw new System.InvalidOperationException(
"BuildSet called without any setting; callers must validate CountSelectedSettings == 1 first."),
};
return new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest
{
MonitorNumber = inputs.MonitorNumber,
MonitorId = inputs.MonitorId,
Setting = settingName,
RawValue = rawValue,
ConfirmPowerOff = inputs.ConfirmPowerOff,
},
};
}
/// <summary>Builds an <c>up</c>/<c>down</c> request envelope from the already-validated inputs.
/// Exactly one continuous-setting flag in <paramref name="inputs"/> must be true.
/// <paramref name="command"/> is the subcommand name (<c>up</c> or <c>down</c>).</summary>
public static CliRequestEnvelope BuildAdjust(string command, AdjustCommandInputs inputs)
{
var settingName = inputs switch
{
{ Brightness: true } => CliSettingNames.Brightness,
{ Contrast: true } => CliSettingNames.Contrast,
{ Volume: true } => CliSettingNames.Volume,
_ => throw new System.InvalidOperationException(
"BuildAdjust called without any setting; callers must validate CountSelectedSettings == 1 first."),
};
return new CliRequestEnvelope
{
Command = command,
Adjust = new AdjustRequest
{
MonitorNumber = inputs.MonitorNumber,
MonitorId = inputs.MonitorId,
Setting = settingName,
Step = inputs.Step,
},
};
}
/// <summary>Builds a <c>capabilities</c> request envelope.</summary>
public static CliRequestEnvelope BuildCapabilities(int? monitorNumber, string? monitorId, string? settingFilter) => new()
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest
{
MonitorNumber = monitorNumber,
MonitorId = monitorId,
SettingFilter = settingFilter,
},
};
/// <summary>Builds a <c>profiles</c> request envelope.</summary>
public static CliRequestEnvelope BuildProfiles() => new()
{
Command = CliCommandNames.Profiles,
};
/// <summary>Builds an <c>apply-profile</c> request envelope.</summary>
public static CliRequestEnvelope BuildApplyProfile(int profileId) => new()
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = profileId },
};
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Cli.Output;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// Encapsulates the common IPC dispatch flow: serialize envelope → send → check
/// provider-unavailable → deserialize response → render → return exit code.
/// <para>
/// The <see cref="SendAsync"/> delegate is injected so the dispatch core can be unit-tested
/// with a stub without standing up a real named-pipe server.
/// </para>
/// </summary>
public sealed class IpcDispatcher
{
/// <summary>
/// Signature that matches <see cref="CliPipeClient.SendAsync"/>. Inject a stub in tests.
/// </summary>
public delegate Task<string?> SendDelegate(string requestJson, TimeSpan connectTimeout, CancellationToken ct);
private readonly SendDelegate _send;
private readonly ICliOutput _output;
private readonly TimeSpan _connectTimeout;
public IpcDispatcher(SendDelegate send, ICliOutput output, TimeSpan connectTimeout)
{
_send = send;
_output = output;
_connectTimeout = connectTimeout;
}
/// <summary>
/// Convenience constructor that uses a real <see cref="CliPipeClient"/> instance.
/// </summary>
public IpcDispatcher(ICliOutput output, TimeSpan connectTimeout)
: this(new CliPipeClient().SendAsync, output, connectTimeout)
{
}
// ── per-command dispatch helpers ─────────────────────────────────────────
public Task<int> SendListAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliListResult, _output.WriteListResult, ct);
public Task<int> SendGetAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliGetResult, _output.WriteGetResult, ct);
public Task<int> SendSetAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliSetResult, _output.WriteSetResult, ct);
public Task<int> SendCapabilitiesAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliCapabilitiesResult, _output.WriteCapabilitiesResult, ct);
public Task<int> SendProfilesAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliProfileListResult, _output.WriteProfileListResult, ct);
// up/down reuse the set response shape (CliSetResult before/after) and the set renderer.
public Task<int> SendAdjustAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliSetResult, _output.WriteSetResult, ct);
// apply-profile is best-effort: once the profile exists it always succeeds (exit 0). A missing
// profile is reported as an error envelope (ARGUMENT_ERROR / exit 7) via the shared error flow.
public Task<int> SendApplyProfileAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliApplyProfileResult, _output.WriteApplyProfileResult, ct);
// All success envelopes map to exit 0 (the shared success path).
private Task<int> SendAsync<T>(CliRequestEnvelope envelope, JsonTypeInfo<T> typeInfo, Action<T> write, CancellationToken ct)
where T : class
=> SendAndRenderAsync(envelope, typeInfo, write, static _ => CliExitCodes.Ok, ct);
// ── core flow ────────────────────────────────────────────────────────────
private async Task<int> SendAndRenderAsync<T>(
CliRequestEnvelope envelope,
JsonTypeInfo<T> typeInfo,
Action<T> write,
Func<T, int> exitCode,
CancellationToken ct)
where T : class
{
var requestJson = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var respJson = await _send(requestJson, _connectTimeout, ct);
if (respJson is null)
{
return WriteProviderUnavailable(envelope.Command);
}
// The app stamps an explicit IsError discriminator on every response (see CliResponseHeader):
// error envelopes set it true; all success DTOs set it false — including apply-profile partial
// failures, which are still success envelopes and report their outcome via ExitCode. Read the
// flag first, then deserialize as the matching concrete type.
var header = TryReadHeader(respJson);
if (header is { IsError: true })
{
try
{
var error = JsonSerializer.Deserialize(respJson, ContractsJsonContext.Default.CliErrorResult);
if (error is not null)
{
_output.WriteError(error);
return error.Error.ExitCode;
}
}
catch (JsonException)
{
}
// Flagged as an error but the envelope did not deserialize — treat as a schema mismatch.
_output.WriteError(BuildInternalError(envelope.Command, Resources.Error_DeserializeMismatch));
return CliExitCodes.InternalError;
}
try
{
var result = JsonSerializer.Deserialize(respJson, typeInfo)
?? throw new JsonException($"Deserialized {typeof(T).Name} was null.");
write(result);
return exitCode(result);
}
catch (JsonException)
{
// A non-error response that failed to deserialize as the expected success type — likely a
// schema mismatch between CLI and app versions.
_output.WriteError(BuildInternalError(envelope.Command, Resources.Error_DeserializeMismatch));
return CliExitCodes.InternalError;
}
}
private static CliResponseHeader? TryReadHeader(string respJson)
{
try
{
return JsonSerializer.Deserialize(respJson, ContractsJsonContext.Default.CliResponseHeader);
}
catch (JsonException)
{
return null;
}
}
private int WriteProviderUnavailable(string command)
{
_output.WriteError(new CliErrorResult
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.ProviderUnavailable,
Message = Resources.Error_ProviderUnavailable,
},
});
return CliExitCodes.ProviderUnavailable;
}
private static CliErrorResult BuildInternalError(string command, string message) => new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.InternalError,
Message = message,
},
};
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// Authenticates that the process on the other end of a connected named-pipe stream is exactly
/// the sibling <c>PowerToys.PowerDisplay.exe</c> next to this CLI executable.
/// <para>
/// This defends against a different (and possibly hostile) process squatting on the well-known
/// pipe name before the real app starts. It works even when the server (<c>PowerToys.PowerDisplay.exe</c>)
/// is elevated and the CLI is not: <c>PROCESS_QUERY_LIMITED_INFORMATION</c> only requires the ability
/// to open a handle to the process, not equal or higher privilege than the target.
/// </para>
/// </summary>
internal static partial class PipeServerIdentity
{
/// <summary>The only file name this CLI ever trusts as a pipe server.</summary>
internal const string ExpectedServerFileName = "PowerToys.PowerDisplay.exe";
private const uint ProcessQueryLimitedInformation = 0x1000;
// Generously larger than MAX_PATH; QueryFullProcessImageNameW never needs more than this for a
// real installed-app path, and a short-lived stackalloc keeps this allocation-free.
private const int ImageNameBufferLength = 4096;
/// <summary>
/// Returns <see langword="true"/> when <paramref name="pipe"/>'s server process image path matches
/// the sibling <see cref="ExpectedServerFileName"/> in <see cref="AppContext.BaseDirectory"/>.
/// </summary>
/// <param name="pipe">A connected pipe stream (client- or server-side).</param>
public static bool IsTrustedServer(PipeStream pipe)
=> IsTrustedServer(pipe, Path.Combine(AppContext.BaseDirectory, ExpectedServerFileName));
/// <summary>
/// Overload that accepts an explicit expected full path, so tests can verify the real
/// <c>GetNamedPipeServerProcessId</c> / <c>QueryFullProcessImageNameW</c> round trip without
/// depending on <see cref="AppContext.BaseDirectory"/> or a real <c>PowerToys.PowerDisplay.exe</c>.
/// </summary>
internal static bool IsTrustedServer(PipeStream pipe, string expectedFullPath)
{
ArgumentNullException.ThrowIfNull(pipe);
ArgumentException.ThrowIfNullOrEmpty(expectedFullPath);
if (!GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId))
{
return false;
}
string? actualPath = TryGetProcessImagePath(serverProcessId);
return actualPath is not null && PathsMatch(actualPath, expectedFullPath);
}
/// <summary>Pure, case-insensitive full-path comparison. Exposed for focused unit testing.</summary>
internal static bool PathsMatch(string actualFullPath, string expectedFullPath)
=> string.Equals(
Path.GetFullPath(actualFullPath),
Path.GetFullPath(expectedFullPath),
StringComparison.OrdinalIgnoreCase);
private static unsafe string? TryGetProcessImagePath(uint processId)
{
IntPtr processHandle = OpenProcess(ProcessQueryLimitedInformation, bInheritHandle: false, processId);
if (processHandle == IntPtr.Zero)
{
return null;
}
try
{
Span<char> buffer = stackalloc char[ImageNameBufferLength];
uint size = (uint)buffer.Length;
fixed (char* pBuffer = buffer)
{
if (!QueryFullProcessImageNameW(processHandle, 0, pBuffer, ref size))
{
return null;
}
}
return new string(buffer[..(int)size]);
}
finally
{
// Always close the handle, on every return path (success, failure, or exception).
CloseHandle(processHandle);
}
}
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId);
[LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "QueryFullProcessImageNameW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static unsafe partial bool QueryFullProcessImageNameW(IntPtr hProcess, uint dwFlags, char* lpExeName, ref uint lpdwSize);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool CloseHandle(IntPtr hObject);
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Globalization;
using PowerDisplay.Cli.Properties;
namespace PowerDisplay.Cli.Options;
/// <summary>
/// Shared option instances. Same <see cref="Option{T}"/> instance is reused across
/// subcommands so <c>parseResult.GetValueForOption</c> in dispatch code can rely on
/// reference identity.
/// </summary>
public static class CliOptions
{
public static readonly Option<int[]> MonitorNumber = new(
new[] { "--monitor-number", "-n" },
ParseMonitorNumbers,
isDefault: false,
description: "Index(es) of the monitor(s) (1-based), comma-separated for multiple (e.g. 1,2,3). Multiple monitors are applied together by set/up/down. Run 'PowerToys.PowerDisplay.Cli.exe list' to discover.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> MonitorId = new(
["--monitor-id", "-i"],
"Stable monitor ID (DevicePath-derived). Wins if --monitor-number is also provided.")
{
Arity = ArgumentArity.ZeroOrOne,
};
public static readonly Option<string?> SettingFilter = new(
["--setting"],
"Restrict 'get' to a single setting name (e.g. brightness, input-source).")
{
Arity = ArgumentArity.ZeroOrOne,
};
// --- set: continuous ---
public static readonly Option<int?> Brightness = new(
["--brightness"],
"Brightness percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<int?> Contrast = new(
["--contrast"],
"Contrast percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<int?> Volume = new(
["--volume"],
"Volume percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
// --- up/down: no-value setting flags (exactly one) ---
// These intentionally reuse the same alias strings (--brightness/--contrast/--volume) as the
// set-command Option<int?> instances above. There is no conflict: each Option instance is added
// only to its own subcommand (set gets the int? options; up/down get these bool flags), and
// System.CommandLine scopes alias resolution per command. Do NOT add both variants to one command.
//
// Arity is Zero (a pure presence flag), not ZeroOrOne: ZeroOrOne lets the option greedily swallow
// a following bareword, so `up --brightness false` would bind "false" as the flag value and then
// report "no setting specified" — contradicting the documented "no value" contract. Zero rejects
// any attached value while `up --brightness` still resolves to true.
public static readonly Option<bool> BrightnessFlag = new(
["--brightness"],
"Adjust brightness (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<bool> ContrastFlag = new(
["--contrast"],
"Adjust contrast (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<bool> VolumeFlag = new(
["--volume"],
"Adjust volume (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<int?> Step = new(
["--step"],
"Amount to raise/lower by. Defaults to the PowerDisplay mouse_wheel_increment setting. Must be >= 0.")
{
Arity = ArgumentArity.ExactlyOne,
};
// --- set: discrete ---
public static readonly Option<string?> ColorTemperature = new(
["--color-temperature"],
"Hex VCP value (e.g. 0x05). Run 'PowerToys.PowerDisplay.Cli.exe capabilities --setting color-temperature' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> InputSource = new(
["--input-source"],
"Hex VCP value (e.g. 0x11). Run 'PowerToys.PowerDisplay.Cli.exe capabilities --setting input-source' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> PowerState = new(
["--power-state"],
"Hex VCP value (e.g. 0x01=On, 0x04=Off (DPM)). Run 'PowerToys.PowerDisplay.Cli.exe capabilities --setting power-state' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> Orientation = new(
["--orientation"],
"Rotation in degrees: 0, 90, 180, or 270.")
{
Arity = ArgumentArity.ExactlyOne,
};
// Arity is Zero (a pure presence flag), not ZeroOrOne: a ZeroOrOne bool greedily swallows a
// following bareword that parses as a bool. Since --quiet is a global option, `apply-profile
// --quiet true` would otherwise bind "true" as the flag value and leave apply-profile with no
// name (a misleading "Required argument missing"), so a profile literally named "true"/"false"
// could not be applied. Zero rejects any attached value while a bare --quiet still resolves to
// true. Mirrors the up/down setting flags above.
public static readonly Option<bool> Quiet = new(
["--quiet"],
"Suppress warning messages on stderr.")
{
Arity = ArgumentArity.Zero,
};
// Arity is Zero (a pure presence flag), not ZeroOrOne: same greedy-swallow reasoning as --quiet
// and the up/down setting flags. A bare --confirm-power-off resolves to true.
public static readonly Option<bool> ConfirmPowerOff = new(
["--confirm-power-off"],
"Required to apply a power-state that powers the display off or puts it to sleep (Standby/Suspend/Off).")
{
Arity = ArgumentArity.Zero,
};
// --- apply-profile ---
public static readonly Argument<int> ProfileId = new(
"id",
"Numeric id of the profile to apply. Run 'PowerToys.PowerDisplay.Cli.exe profiles' to list them.")
{
Arity = ArgumentArity.ExactlyOne,
};
static CliOptions()
{
// Reject a negative --step at parse time so it flows through the single ArgumentError
// envelope instead of an unfriendly framework message. 0 is allowed (a no-op adjust).
Step.AddValidator(result =>
{
if (result.Tokens.Count != 0
&& int.TryParse(result.Tokens[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var step)
&& step < 0)
{
result.ErrorMessage = Resources.Error_NegativeStep;
}
});
}
// Parses the single --monitor-number/-n token as a comma-separated list of 1-based indices
// (e.g. "1,2,3"). Duplicates are collapsed preserving first-seen order; any empty, non-integer,
// or non-positive element flows through the single ArgumentError envelope like other parse failures.
// Returns an empty array only when the option is absent.
private static int[] ParseMonitorNumbers(ArgumentResult result)
{
if (result.Tokens.Count == 0)
{
return Array.Empty<int>();
}
var raw = result.Tokens[0].Value;
var parts = raw.Split(',');
var numbers = new List<int>(parts.Length);
foreach (var part in parts)
{
var trimmed = part.Trim();
if (trimmed.Length == 0
|| !int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
|| number < 1)
{
result.ErrorMessage = Resources.Error_InvalidMonitorNumber(raw);
return Array.Empty<int>();
}
if (!numbers.Contains(number))
{
numbers.Add(number);
}
}
return numbers.ToArray();
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Maps an app-produced <see cref="CliError"/> to its localized (message, hint) pair, keyed by
/// <see cref="CliError.MessageId"/> and filled from the error's structured fields (Setting, Value).
/// The app sends only ids + data (no prose); this is the single place the CLI owns the human text.
/// <para>
/// Hints are generated here — the CLI already knows the valid setting lists, so the app need not
/// send them. An unrecognized or empty <see cref="CliError.MessageId"/> falls back to the app's
/// English <see cref="CliError.Message"/> / <see cref="CliError.Hint"/> (version-skew safety).
/// </para>
/// </summary>
internal static class CliErrorLocalizer
{
private static readonly string AllSettings = string.Join(", ", CliSettingNames.All);
private static readonly string DiscreteSettings = string.Join(
", ", CliSettingNames.ColorTemperature, CliSettingNames.InputSource, CliSettingNames.PowerState);
private static readonly string ContinuousSettings = string.Join(
", ", CliSettingNames.Brightness, CliSettingNames.Contrast, CliSettingNames.Volume);
/// <summary>Returns the localized message and optional hint for <paramref name="e"/>.</summary>
public static (string Message, string? Hint) Localize(CliError e)
{
var value = e.Value ?? string.Empty;
var setting = e.Setting ?? string.Empty;
return e.MessageId switch
{
CliMessageIds.OutOfRange => (Resources.ErrMsg_OutOfRange(value, setting), null),
CliMessageIds.InvalidInteger => (Resources.ErrMsg_InvalidInteger(value, setting), null),
CliMessageIds.InvalidDiscrete => (Resources.ErrMsg_InvalidDiscrete(value, setting), Resources.Hint_UseHexVcp),
CliMessageIds.DiscreteNotInSet => (Resources.ErrMsg_DiscreteNotInSet(value, setting), Resources.Hint_UseHexVcp),
CliMessageIds.InvalidOrientation => (Resources.ErrMsg_InvalidOrientation(value), Resources.Hint_Orientation),
CliMessageIds.Unsupported => (Resources.ErrMsg_Unsupported(setting), null),
CliMessageIds.PowerBlankingConfirm => (Resources.ErrMsg_PowerBlankingConfirm, Resources.Hint_ConfirmPowerOff),
CliMessageIds.HardwareFailure => (Resources.ErrMsg_HardwareFailure, null),
CliMessageIds.UnknownSetting => (Resources.ErrMsg_UnknownSetting(value), Resources.Hint_ValidSettings(AllSettings)),
CliMessageIds.NotDiscreteSetting => (Resources.ErrMsg_NotDiscreteSetting(value), Resources.Hint_ValidDiscreteSettings(DiscreteSettings)),
CliMessageIds.SelectorMissing => (Resources.ErrMsg_SelectorMissing, Resources.Hint_SelectorMissing),
CliMessageIds.MonitorNotFoundNumber => (Resources.ErrMsg_MonitorNotFoundNumber(value), Resources.Hint_RunList),
CliMessageIds.MonitorNotFoundId => (Resources.ErrMsg_MonitorNotFoundId(value), Resources.Hint_RunList),
CliMessageIds.UnknownSettingAdjust => (Resources.ErrMsg_UnknownSetting(value), Resources.Hint_AdjustSettings(ContinuousSettings)),
CliMessageIds.NotAdjustable => (Resources.ErrMsg_NotAdjustable(setting), Resources.Hint_AdjustSettings(ContinuousSettings)),
CliMessageIds.AdjustValueUnknown => (Resources.ErrMsg_AdjustValueUnknown(setting), Resources.Hint_UseSetForAbsolute),
CliMessageIds.ProfileNotFound => (Resources.ErrMsg_ProfileNotFound(value), Resources.Hint_RunProfiles),
CliMessageIds.UnknownCommand => (Resources.ErrMsg_UnknownCommand(value), null),
CliMessageIds.InternalError => (Resources.ErrMsg_InternalError, null),
// Unknown/empty id: fall back to whatever English prose the app supplied.
_ => (e.Message, e.Hint),
};
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Abstraction over CLI output rendering (today only <see cref="TextCliOutput"/>; the seam also
/// lets tests capture output). Each command builds the typed result record and hands it to one of
/// these methods. Errors are routed through <see cref="WriteError"/> regardless of which command
/// produced them.
/// </summary>
public interface ICliOutput
{
void WriteListResult(CliListResult result);
void WriteSetResult(CliSetResult result);
void WriteGetResult(CliGetResult result);
void WriteCapabilitiesResult(CliCapabilitiesResult result);
void WriteProfileListResult(CliProfileListResult result);
void WriteApplyProfileResult(CliApplyProfileResult result);
void WriteError(CliErrorResult result);
void WriteWarning(string message);
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Linq;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Human-readable text output. Success lines go to stdout; warnings and errors go
/// to stderr so scripts that capture only stdout receive a clean stream.
/// </summary>
public sealed class TextCliOutput : ICliOutput
{
private readonly TextWriter _stdout;
private readonly TextWriter _stderr;
private readonly bool _quiet;
public TextCliOutput(bool quiet = false)
: this(Console.Out, Console.Error, quiet)
{
}
public TextCliOutput(TextWriter stdout, TextWriter stderr, bool quiet = false)
{
_stdout = stdout;
_stderr = stderr;
_quiet = quiet;
}
public void WriteListResult(CliListResult result)
{
if (result.Monitors.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoMonitorsDiscovered);
return;
}
_stdout.WriteLine("# | Name | Method | Monitor ID");
foreach (var m in result.Monitors)
{
_stdout.WriteLine($"{m.Number} | {m.Name} | {m.Method} | {m.Id}");
}
}
public void WriteSetResult(CliSetResult result)
{
var via = string.IsNullOrEmpty(result.Monitor.Method)
? string.Empty
: $" [{result.Monitor.Method}]";
var monitor = $"{MonitorLabel(result.Monitor)}{via}";
var before = result.BeforeDisplay ?? "?";
_stdout.WriteLine($"{monitor}: {result.Setting} {before} → {result.AfterDisplay}");
}
public void WriteGetResult(CliGetResult result)
{
if (result.Monitors.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoMonitorsDiscovered);
return;
}
for (int i = 0; i < result.Monitors.Count; i++)
{
var entry = result.Monitors[i];
if (i > 0)
{
_stdout.WriteLine();
}
_stdout.WriteLine(MonitorLabel(entry.Monitor));
_stdout.WriteLine($" protocol {entry.Monitor.Method}");
_stdout.WriteLine($" id {entry.Monitor.Id}");
foreach (var s in entry.Settings)
{
// Three honest states: the monitor can't do it, it can but discovery couldn't read
// it, or here's the value.
var rendered = !s.Supported ? Resources.Text_NotSupported
: s.Display ?? Resources.Text_Unknown;
_stdout.WriteLine($" {s.Setting,-18} {rendered}");
}
}
}
public void WriteCapabilitiesResult(CliCapabilitiesResult result)
{
var monitor = MonitorLabel(result.Monitor);
_stdout.WriteLine($"{monitor} via {result.CommunicationMethod}");
if (!string.IsNullOrEmpty(result.Model))
{
_stdout.WriteLine($" Model: {result.Model}");
}
if (!string.IsNullOrEmpty(result.MccsVersion))
{
_stdout.WriteLine($" MCCS: {result.MccsVersion}");
}
if (result.VcpCodes.Count == 0)
{
_stdout.WriteLine($" {Resources.Text_NoVcpCapabilities}");
}
else
{
_stdout.WriteLine(" VCP codes:");
foreach (var code in result.VcpCodes)
{
if (code.Continuous)
{
_stdout.WriteLine($" {code.Code} {code.Name} (continuous)");
}
else
{
var values = code.DiscreteValues is null
? Resources.Text_NoValuesReported
: string.Join(", ", code.DiscreteValues);
_stdout.WriteLine($" {code.Code} {code.Name}: {values}");
}
}
}
if (!string.IsNullOrEmpty(result.RawCapabilities))
{
_stdout.WriteLine($" Raw: {result.RawCapabilities}");
}
}
public void WriteProfileListResult(CliProfileListResult result)
{
if (result.Profiles.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoProfilesSaved);
return;
}
_stdout.WriteLine("Id | Name | Monitors | Last modified");
foreach (var p in result.Profiles)
{
_stdout.WriteLine($"{p.Id} | {p.Name} | {p.MonitorCount} | {p.LastModified}");
}
}
public void WriteApplyProfileResult(CliApplyProfileResult result)
{
// apply-profile is best-effort: print a single confirmation line that never claims hardware
// success. Per-setting outcomes are intentionally not reported (see CliApplyProfileResult /
// ApplyProfileForCliAsync).
_stdout.WriteLine(Resources.Text_ProfileProcessedBestEffort(result.Profile));
}
public void WriteError(CliErrorResult result)
{
var err = result.Error;
var (message, hint) = CliErrorLocalizer.Localize(err);
_stderr.WriteLine($"{Resources.Label_Error}: {message}");
if (result.Monitor is { Number: > 0 })
{
_stderr.WriteLine($" {Resources.Label_Monitor}: {MonitorLabel(result.Monitor)}");
}
if (!string.IsNullOrEmpty(err.ExpectedRange))
{
_stderr.WriteLine($" {Resources.Label_Expected}: {Resources.Text_ExpectedInteger(err.ExpectedRange)}");
}
if (err.Supported is { Count: > 0 })
{
_stderr.WriteLine($" {Resources.Label_Supported}: " + string.Join(", ", err.Supported.Select(v => $"{v.Name} ({v.Vcp})")));
}
if (!string.IsNullOrEmpty(err.Detail))
{
_stderr.WriteLine($" {Resources.Label_Diagnostic}: {err.Detail}");
}
if (!string.IsNullOrEmpty(hint))
{
_stderr.WriteLine($" {Resources.Label_Hint}: {hint}");
}
}
public void WriteWarning(string message)
{
if (!_quiet)
{
_stderr.WriteLine(message);
}
}
private static string MonitorLabel(CliMonitorRef m) => $"Monitor {m.Number} ({m.Name})";
}

View File

@@ -0,0 +1,59 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>PowerDisplay.Cli</RootNamespace>
<ApplicationIcon>..\PowerDisplay\Assets\PowerDisplay\PowerDisplay.ico</ApplicationIcon>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<AssemblyName>PowerToys.PowerDisplay.Cli</AssemblyName>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<!-- Globalization is enabled (not invariant) so the human-readable text output can be localized
via satellite resources. The machine contract (JSON keys, error codes, status strings, exit
codes, VCP names) stays culture-independent because every parse/format site passes
CultureInfo.InvariantCulture explicitly. Set explicitly below (rather than relying on the
SDK default) so a future default flip — or copying the common AOT "set it true to shrink the
binary" guidance — cannot silently disable satellite-resource localization. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- Native AOT Configuration -->
<PropertyGroup>
<PublishSingleFile>false</PublishSingleFile>
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
<PublishAot>true</PublishAot>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
<ItemGroup>
<!-- Hide build log files from Solution Explorer -->
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="PowerDisplay.Cli.UnitTests" />
</ItemGroup>
<ItemGroup>
<!-- Add WindowsDesktop.App framework reference to align System.CodeDom.dll version
(pulled in transitively via System.Management) with the other apps, which get it from
the WindowsDesktop runtime pack instead of the NuGet package. Without this, the deps.json
audit fails because this app ships the older package version of System.CodeDom.dll.
This does NOT enable WPF/WinForms, it only ensures consistent runtime DLL versions. -->
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,555 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Cli.Options;
using PowerDisplay.Cli.Output;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli;
public static class Program
{
// Overall wall-clock deadline for one CLI invocation (pipe connect + request/response + any
// hardware write). There is deliberately no --timeout option: the CLI is a thin client that
// blocks waiting on the app, and the app's DDC/CI writes are synchronous and cannot be cancelled
// mid-call, so the client must bound its own wait or a slow/stuck monitor (or a hung app) would
// hang it indefinitely. 5s covers a normal connect plus one VCP exchange with margin. When it
// elapses the invocation is reported as TIMEOUT (exit 8).
internal static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5);
// Bound on just the pipe-connect phase. MUST stay strictly less than OperationTimeout:
// NamedPipeClientStream.ConnectAsync polls until either its own timeout (-> TimeoutException,
// which CliPipeClient maps to a null response -> PROVIDER_UNAVAILABLE, exit 10) or ct
// cancellation. If this equalled OperationTimeout, the deadline timer would cancel ct at the same
// instant and win the race, so a not-running app would be misreported as TIMEOUT (exit 8) after a
// full 5s wait instead of a fast, correct PROVIDER_UNAVAILABLE ("PowerDisplay is not running").
// A running app connects near-instantly, so the shorter bound never affects the normal path.
internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(2);
// Extra wall-clock budget added per additional monitor in a comma-separated -n batch (set/up/down).
// The base OperationTimeout covers the first target (connect + one VCP exchange); each further
// monitor is a fast reconnect plus one more exchange, so a smaller per-monitor increment suffices.
internal static readonly TimeSpan PerAdditionalMonitorTimeout = TimeSpan.FromSeconds(3);
// Canonical args for routing any version request through the default invocation pipeline's
// version renderer (static readonly to satisfy CA1861 — the array is passed, never mutated).
private static readonly string[] VersionArgs = { "--version" };
// Stable program identifier stamped into the `command` field of root-level error envelopes.
// For an error that resolves to the RootCommand (e.g. an unrecognized top-level option),
// CommandResult.Command.Name is the auto-derived executable name ("PowerToys.PowerDisplay.Cli");
// mapping it to this constant keeps the machine-readable field a documented command identifier.
private const string ProgramCommandLabel = "powerdisplay";
// The command name for the error envelope: a real subcommand keeps its name; a root-level error
// is reported as the program label instead of leaking the binary name.
private static string CommandLabelFor(ParseResult parseResult)
=> parseResult.CommandResult.Command is RootCommand
? ProgramCommandLabel
: parseResult.CommandResult.Command.Name;
// Overall deadline for one invocation, scaled by the number of per-monitor IPC round-trips. A
// comma-separated -n batch (set/up/down) dispatches once per monitor, so each additional target
// adds PerAdditionalMonitorTimeout; a single-target invocation keeps the base OperationTimeout.
internal static TimeSpan ComputeOperationTimeout(int targetCount)
=> OperationTimeout + (PerAdditionalMonitorTimeout * Math.Max(0, targetCount - 1));
// Counts how many per-monitor dispatches a parsed invocation will make, so the overall deadline
// can scale with a -n batch. Only the write commands (set/up/down) batch; a monitor id wins and
// collapses to one target. Everything else is a single round-trip.
internal static int CountDispatchTargets(ParseResult parseResult)
{
var command = parseResult.CommandResult.Command.Name;
if (command is not (CliCommandNames.Set or CliCommandNames.Up or CliCommandNames.Down))
{
return 1;
}
if (!string.IsNullOrEmpty(parseResult.GetValueForOption(CliOptions.MonitorId)))
{
return 1;
}
var numbers = parseResult.GetValueForOption(CliOptions.MonitorNumber);
return numbers is { Length: > 1 } ? numbers.Length : 1;
}
public static async Task<int> Main(string[] args)
{
// Emit UTF-8 so non-ASCII glyphs in human-readable output (the → arrow, ° degree sign,
// … ellipsis) and any UTF-8 JSON render correctly instead of as '?' on legacy code pages.
TrySetUtf8Output();
var root = new PowerDisplayRootCommand();
var parser = new Parser(root);
var parseResult = parser.Parse(args);
// Help / version short-circuit through the default invocation pipeline (which owns
// the version + help renderers). Done BEFORE the logger is created so a pure
// --help/--version invocation has no file-system side effects.
if (parseResult.Tokens.Count == 0 || HasHelpToken(parseResult))
{
return await root.InvokeAsync(args);
}
if (IsVersionRequest(parseResult))
{
// Route through the canonical root `--version` invocation rather than re-invoking the
// original args. This also covers `apply-profile --version`, where the version token was
// greedily bound to the profile-name argument (see IsVersionRequest) and replaying args
// would instead dispatch "apply a profile literally named --version".
return await root.InvokeAsync(VersionArgs);
}
var quiet = parseResult.GetValueForOption(CliOptions.Quiet);
ICliOutput output = new TextCliOutput(quiet);
if (parseResult.Errors.Count > 0)
{
// System.CommandLine can report several parse errors for one bad invocation; collapse
// them into a single envelope so consumers always receive exactly one parseable
// object (text output) instead of N concatenated ones.
output.WriteError(BuildParseErrorResult(
CommandLabelFor(parseResult),
parseResult.Errors.Select(e => e.Message)));
return CliExitCodes.ArgumentError;
}
// Logs go to %LOCALAPPDATA%\Microsoft\PowerToys\PowerDisplay\Logs\<version>.
// Guard initialization: an unwritable log path (locked profile, full disk, policy
// redirection) creates the directory / trace listener eagerly and would otherwise throw
// here — OUTSIDE the try below — crashing with a raw stack trace and bypassing the
// single-envelope error contract. The requested operation does not need the log file,
// so degrade to no file listener and continue.
try
{
Logger.InitializeLogger("\\PowerDisplay\\Logs");
}
catch (Exception)
{
}
var timedOut = false;
Timer? timeoutTimer = null;
ConsoleCancelEventHandler? cancelHandler = null;
// A comma-separated -n batch (set/up/down) performs one IPC round-trip per monitor, so the
// overall deadline scales with the number of targets (a single-target invocation keeps 5s).
var operationTimeout = ComputeOperationTimeout(CountDispatchTargets(parseResult));
using var cts = new CancellationTokenSource();
try
{
// Captured in a local so the finally can unsubscribe it. Console.CancelKeyPress is a
// process-global static event; leaving the handler attached would leak a closure over a
// disposed cts across repeated DispatchAsync/Main invocations (e.g. in tests).
cancelHandler = (_, e) =>
{
e.Cancel = true;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
};
Console.CancelKeyPress += cancelHandler;
// Fire the fixed deadline. `timedOut` is set on the timer thread before cts.Cancel(); the
// cancel→token propagation establishes happens-before, so the catch below reads it
// reliably. The flag lets the error envelope distinguish a timeout from a Ctrl+C
// cancellation (both map to exit 8).
timeoutTimer = new Timer(
_ =>
{
timedOut = true;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
},
null,
operationTimeout,
Timeout.InfiniteTimeSpan);
// The dispatcher's own timeout bounds only the pipe-connect phase (ConnectTimeout, shorter
// than OperationTimeout) so a not-running app surfaces as PROVIDER_UNAVAILABLE quickly
// rather than racing the overall deadline into a misleading TIMEOUT.
var dispatcher = new IpcDispatcher(output, ConnectTimeout);
return await DispatchAsync(root, args, parseResult, dispatcher, output, cts.Token);
}
catch (OperationCanceledException)
{
output.WriteError(BuildTimeoutErrorResult(CommandLabelFor(parseResult), timedOut, operationTimeout));
return CliExitCodes.Timeout;
}
catch (Exception ex)
{
Logger.LogError($"PowerDisplay CLI failed: {ex}");
output.WriteError(new CliErrorResult
{
Command = CommandLabelFor(parseResult),
Error = new CliError
{
Code = CliErrorCodes.InternalError,
Message = Resources.Error_UnexpectedError(ex.Message),
},
});
return CliExitCodes.InternalError;
}
finally
{
if (cancelHandler is not null)
{
Console.CancelKeyPress -= cancelHandler;
}
timeoutTimer?.Dispose();
}
}
/// <summary>
/// Routes the parsed command to the appropriate IPC send-and-render helper.
/// Pure-syntactic validation (setting count, setting name) is checked here before
/// any IPC round-trip. Extracted as a static method so tests can drive it directly.
/// </summary>
internal static async Task<int> DispatchAsync(
PowerDisplayRootCommand root,
string[] args,
ParseResult parseResult,
IpcDispatcher dispatcher,
ICliOutput output,
CancellationToken cancellationToken)
{
// Dispatch on the parsed command's name against the shared CliCommandNames constants,
// so no shared reference-equality singletons are required.
switch (parseResult.CommandResult.Command.Name)
{
// ── list ──────────────────────────────────────────────────────────
case CliCommandNames.List:
return await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), cancellationToken);
// ── get ───────────────────────────────────────────────────────────
case CliCommandNames.Get:
{
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
if (!TryGetSingleMonitorNumber(parseResult, output, CliCommandNames.Get, out var monitorNumber))
{
return CliExitCodes.ArgumentError;
}
// CLI-side syntactic validation: reject unknown --setting names here so the error
// is surfaced without a round-trip and matches the existing ARGUMENT_ERROR (7) shape.
if (settingFilter is not null
&& System.Array.IndexOf(CliSettingNames.All, settingFilter.ToLowerInvariant()) < 0)
{
output.WriteError(ArgumentError(
CliCommandNames.Get,
Resources.Error_UnknownSetting(settingFilter),
Resources.Hint_ValidSettings(string.Join(", ", CliSettingNames.All))));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, monitorNumber.HasValue ? new[] { monitorNumber.Value } : System.Array.Empty<int>(), monitorId);
return await dispatcher.SendGetAsync(
CliRequestBuilder.BuildGet(monitorNumber, monitorId, settingFilter),
cancellationToken);
}
// ── set ───────────────────────────────────────────────────────────
case CliCommandNames.Set:
{
var monitorNumbers = parseResult.GetValueForOption(CliOptions.MonitorNumber) ?? System.Array.Empty<int>();
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
SetCommandInputs MakeSetInputs(int? number) => new()
{
MonitorNumber = number,
MonitorId = monitorId,
Brightness = parseResult.GetValueForOption(CliOptions.Brightness),
Contrast = parseResult.GetValueForOption(CliOptions.Contrast),
Volume = parseResult.GetValueForOption(CliOptions.Volume),
ColorTemperature = parseResult.GetValueForOption(CliOptions.ColorTemperature),
InputSource = parseResult.GetValueForOption(CliOptions.InputSource),
PowerState = parseResult.GetValueForOption(CliOptions.PowerState),
Orientation = parseResult.GetValueForOption(CliOptions.Orientation),
ConfirmPowerOff = parseResult.GetValueForOption(CliOptions.ConfirmPowerOff),
};
// CLI-side syntactic validation: exactly one setting must be specified. The setting is
// independent of the monitor selection, so validate it once before any per-monitor dispatch.
var selected = SetCommand.CountSelectedSettings(MakeSetInputs(null));
if (selected == 0)
{
output.WriteError(ArgumentError(CliCommandNames.Set, Resources.Error_NoSettingSpecified));
return CliExitCodes.ArgumentError;
}
if (selected > 1)
{
output.WriteError(ArgumentError(CliCommandNames.Set, Resources.Error_OnlyOneSetting, Resources.Hint_OnlyOneSetting));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, monitorNumbers, monitorId);
return await DispatchWriteTargetsAsync(
monitorNumbers,
monitorId,
number => dispatcher.SendSetAsync(CliRequestBuilder.BuildSet(MakeSetInputs(number)), cancellationToken));
}
// ── up / down ─────────────────────────────────────────────────────
case CliCommandNames.Up:
case CliCommandNames.Down:
{
var monitorNumbers = parseResult.GetValueForOption(CliOptions.MonitorNumber) ?? System.Array.Empty<int>();
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
var commandName = parseResult.CommandResult.Command.Name;
AdjustCommandInputs MakeAdjustInputs(int? number) => new()
{
MonitorNumber = number,
MonitorId = monitorId,
Brightness = parseResult.GetValueForOption(CliOptions.BrightnessFlag),
Contrast = parseResult.GetValueForOption(CliOptions.ContrastFlag),
Volume = parseResult.GetValueForOption(CliOptions.VolumeFlag),
Step = parseResult.GetValueForOption(CliOptions.Step),
};
// CLI-side syntactic validation: exactly one continuous setting must be specified.
var selected = AdjustCommand.CountSelectedSettings(MakeAdjustInputs(null));
if (selected == 0)
{
output.WriteError(ArgumentError(commandName, Resources.Error_NoAdjustSettingSpecified));
return CliExitCodes.ArgumentError;
}
if (selected > 1)
{
output.WriteError(ArgumentError(commandName, Resources.Error_OnlyOneSetting, Resources.Hint_OnlyOneSetting));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, monitorNumbers, monitorId);
return await DispatchWriteTargetsAsync(
monitorNumbers,
monitorId,
number => dispatcher.SendAdjustAsync(CliRequestBuilder.BuildAdjust(commandName, MakeAdjustInputs(number)), cancellationToken));
}
// ── capabilities ──────────────────────────────────────────────────
case CliCommandNames.Capabilities:
{
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
if (!TryGetSingleMonitorNumber(parseResult, output, CliCommandNames.Capabilities, out var monitorNumber))
{
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, monitorNumber.HasValue ? new[] { monitorNumber.Value } : System.Array.Empty<int>(), monitorId);
// An out-of-range --setting (not one of the 3 discrete settings) is validated app-side
// and comes back as a single ARGUMENT_ERROR envelope.
return await dispatcher.SendCapabilitiesAsync(
CliRequestBuilder.BuildCapabilities(monitorNumber, monitorId, settingFilter),
cancellationToken);
}
// ── profiles ──────────────────────────────────────────────────────
case CliCommandNames.Profiles:
return await dispatcher.SendProfilesAsync(CliRequestBuilder.BuildProfiles(), cancellationToken);
// ── apply-profile ─────────────────────────────────────────────────
case CliCommandNames.ApplyProfile:
{
var profileId = parseResult.GetValueForArgument(CliOptions.ProfileId);
return await dispatcher.SendApplyProfileAsync(
CliRequestBuilder.BuildApplyProfile(profileId),
cancellationToken);
}
default:
return await root.InvokeAsync(args);
}
}
// Carry-forward: the app discards -n when -i is also supplied; surface that warning
// CLI-side without a round-trip. Shared by the get/set/up/down/capabilities branches. Internal
// (rather than private) so the complete-ignored-list formatting is directly unit-testable.
internal static void WarnIfMonitorNumberIgnored(ICliOutput output, IReadOnlyList<int> monitorNumbers, string? monitorId)
{
if (monitorNumbers.Count > 0 && !string.IsNullOrEmpty(monitorId))
{
var ignored = string.Join(",", monitorNumbers.Select(n => n.ToString(CultureInfo.InvariantCulture)));
output.WriteWarning(Resources.Warn_MonitorNumberIgnored(ignored));
}
}
// Resolves the -n option to a single optional 1-based index for the single-monitor read commands
// (get, capabilities). A comma-separated batch is rejected with an ARGUMENT_ERROR — only the write
// commands (set/up/down) apply to multiple monitors. Returns false (and emits the error) on a batch.
private static bool TryGetSingleMonitorNumber(ParseResult parseResult, ICliOutput output, string command, out int? monitorNumber)
{
var numbers = parseResult.GetValueForOption(CliOptions.MonitorNumber) ?? System.Array.Empty<int>();
if (numbers.Length > 1)
{
output.WriteError(ArgumentError(command, Resources.Error_SingleMonitorOnly));
monitorNumber = null;
return false;
}
monitorNumber = numbers.Length == 1 ? numbers[0] : (int?)null;
return true;
}
// Dispatches a write command (set/up/down) to each selected monitor, rendering each per-monitor
// result/error via <paramref name="dispatchOne"/> and returning the aggregated exit code. A monitor
// id (when present) wins and collapses to a single dispatch; 0 or 1 monitor numbers also dispatch
// once (preserving the single-monitor path). For a real batch (>1 numbers, no id) it dispatches per
// number, aborting early on PROVIDER_UNAVAILABLE (the app is not running) and otherwise aggregating
// the worst outcome (see WorseBatchExit). Extracted (internal) so the routing/aggregation is testable.
internal static async Task<int> DispatchWriteTargetsAsync(
IReadOnlyList<int> monitorNumbers,
string? monitorId,
Func<int?, Task<int>> dispatchOne)
{
var hasId = !string.IsNullOrEmpty(monitorId);
if (hasId || monitorNumbers.Count <= 1)
{
int? single = !hasId && monitorNumbers.Count == 1 ? monitorNumbers[0] : (int?)null;
return await dispatchOne(single);
}
var worst = CliExitCodes.Ok;
foreach (var number in monitorNumbers)
{
var exit = await dispatchOne(number);
if (exit == CliExitCodes.ProviderUnavailable)
{
return exit;
}
worst = WorseBatchExit(worst, exit);
}
return worst;
}
// Folds a per-monitor exit code into the running batch exit code. UNSUPPORTED_FEATURE does not count
// as a batch failure (a monitor that lacks the setting is skipped, mirroring apply-profile), and a
// higher exit code ranks as more severe (HardwareFailure > InvalidDiscreteValue > OutOfRange >
// MonitorNotFound), so the batch reports its worst outcome.
internal static int WorseBatchExit(int current, int next)
{
if (next == CliExitCodes.Ok || next == CliExitCodes.UnsupportedFeature)
{
return current;
}
return next > current ? next : current;
}
public static bool HasHelpToken(ParseResult parseResult)
=> parseResult.Tokens.Any(t =>
(t.Type != TokenType.Argument || parseResult.Errors.Count > 0)
&& IsHelpToken(t.Value));
private static bool IsHelpToken(string token)
=> token is "--help" or "-h" or "-?" or "/?";
public static bool HasVersionToken(ParseResult parseResult)
=> parseResult.Tokens.Any(t =>
(t.Type != TokenType.Argument || parseResult.Errors.Count > 0)
&& t.Value == "--version");
public static bool IsVersionRequest(ParseResult parseResult)
=> HasVersionToken(parseResult)
&& (parseResult.CommandResult.Command is RootCommand
|| parseResult.CommandResult.Command.Name == CliCommandNames.ApplyProfile);
/// <summary>
/// Collapses one or more System.CommandLine parse-error messages into a single
/// <see cref="CliErrorResult"/> so the error stream stays a single parseable envelope.
/// </summary>
public static CliErrorResult BuildParseErrorResult(string command, IEnumerable<string> messages)
{
var combined = string.Join("; ", messages.Where(m => !string.IsNullOrWhiteSpace(m)));
return ArgumentError(command, combined.Length == 0 ? Resources.Error_InvalidArguments : combined);
}
// Single ARGUMENT_ERROR envelope shape, shared by the syntactic-validation sites in
// DispatchAsync and by BuildParseErrorResult. Setting/Hint default to null (omitted from JSON).
private static CliErrorResult ArgumentError(string command, string message, string? hint = null)
=> new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
Message = message,
Hint = hint,
},
};
// Shared TIMEOUT envelope for the OperationCanceledException catch path. Distinguishes the fixed
// deadline elapsing (timedOut) from a Ctrl+C cancellation; both map to exit 8. The deadline is the
// per-invocation timeout (scaled by monitor count for a -n batch), so the message reports it verbatim.
private static CliErrorResult BuildTimeoutErrorResult(string command, bool timedOut, TimeSpan operationTimeout)
=> new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.Timeout,
Message = timedOut
? Resources.Error_TimedOut((int)operationTimeout.TotalSeconds)
: Resources.Error_Cancelled,
},
};
private static void TrySetUtf8Output()
{
try
{
// UTF-8 without a BOM: a leading BOM in redirected/piped output can confuse some
// consumers that don't strip it (e.g. some parsers and shells).
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
catch (IOException)
{
// No real console attached (handles redirected/closed); leave the default encoding.
}
catch (System.Security.SecurityException)
{
// Host policy forbids changing console encoding; not fatal for the operation.
}
}
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Resources;
namespace PowerDisplay.Cli.Properties;
/// <summary>
/// Strongly-typed accessor for the CLI's localizable human-readable strings (Resources.resx,
/// localized into satellite assemblies by the build pipeline).
/// Only prose lives here — error messages/hints and text-mode labels. The machine contract (JSON
/// keys, error <c>code</c> strings, <c>status</c> strings, exit codes, VCP names) stays as invariant
/// literals elsewhere and is never routed through this class.
/// </summary>
internal static class Resources
{
private static readonly ResourceManager Manager =
new("PowerDisplay.Cli.Properties.Resources", typeof(Resources).Assembly);
// ---- plain (no-argument) labels ----
internal static string Text_NoMonitorsDiscovered => Get(nameof(Text_NoMonitorsDiscovered));
internal static string Text_NotSupported => Get(nameof(Text_NotSupported));
internal static string Text_Unknown => Get(nameof(Text_Unknown));
internal static string Text_NoProfilesSaved => Get(nameof(Text_NoProfilesSaved));
internal static string Text_NoVcpCapabilities => Get(nameof(Text_NoVcpCapabilities));
internal static string Text_NoValuesReported => Get(nameof(Text_NoValuesReported));
// ---- error messages / hints (with arguments) ----
internal static string Text_ProfileProcessedBestEffort(string profile) => Format(nameof(Text_ProfileProcessedBestEffort), profile);
internal static string Warn_MonitorNumberIgnored(string numbers) => Format(nameof(Warn_MonitorNumberIgnored), numbers);
internal static string Error_NoSettingSpecified => Get(nameof(Error_NoSettingSpecified));
internal static string Error_OnlyOneSetting => Get(nameof(Error_OnlyOneSetting));
internal static string Hint_OnlyOneSetting => Get(nameof(Hint_OnlyOneSetting));
internal static string Error_UnknownSetting(string setting) => Format(nameof(Error_UnknownSetting), setting);
internal static string Hint_ValidSettings(string settings) => Format(nameof(Hint_ValidSettings), settings);
internal static string Error_TimedOut(int seconds) => Format(nameof(Error_TimedOut), seconds);
internal static string Error_Cancelled => Get(nameof(Error_Cancelled));
internal static string Error_InvalidArguments => Get(nameof(Error_InvalidArguments));
internal static string Error_UnexpectedError(string message) => Format(nameof(Error_UnexpectedError), message);
internal static string Error_ProviderUnavailable => Get(nameof(Error_ProviderUnavailable));
internal static string Error_DeserializeMismatch => Get(nameof(Error_DeserializeMismatch));
internal static string Error_NegativeStep => Get(nameof(Error_NegativeStep));
internal static string Error_NoAdjustSettingSpecified => Get(nameof(Error_NoAdjustSettingSpecified));
internal static string Error_InvalidMonitorNumber(string value) => Format(nameof(Error_InvalidMonitorNumber), value);
internal static string Error_SingleMonitorOnly => Get(nameof(Error_SingleMonitorOnly));
// ---- error-line labels (no arguments) ----
internal static string Label_Error => Get(nameof(Label_Error));
internal static string Label_Monitor => Get(nameof(Label_Monitor));
internal static string Label_Expected => Get(nameof(Label_Expected));
internal static string Label_Supported => Get(nameof(Label_Supported));
internal static string Label_Diagnostic => Get(nameof(Label_Diagnostic));
internal static string Label_Hint => Get(nameof(Label_Hint));
internal static string Text_ExpectedInteger(string range) => Format(nameof(Text_ExpectedInteger), range);
// ---- app-side error message templates (keyed by CliMessageIds) ----
internal static string ErrMsg_OutOfRange(string value, string setting) => Format(nameof(ErrMsg_OutOfRange), value, setting);
internal static string ErrMsg_InvalidInteger(string value, string setting) => Format(nameof(ErrMsg_InvalidInteger), value, setting);
internal static string ErrMsg_InvalidDiscrete(string value, string setting) => Format(nameof(ErrMsg_InvalidDiscrete), value, setting);
internal static string ErrMsg_DiscreteNotInSet(string value, string setting) => Format(nameof(ErrMsg_DiscreteNotInSet), value, setting);
internal static string ErrMsg_InvalidOrientation(string value) => Format(nameof(ErrMsg_InvalidOrientation), value);
internal static string ErrMsg_Unsupported(string setting) => Format(nameof(ErrMsg_Unsupported), setting);
internal static string ErrMsg_PowerBlankingConfirm => Get(nameof(ErrMsg_PowerBlankingConfirm));
internal static string ErrMsg_HardwareFailure => Get(nameof(ErrMsg_HardwareFailure));
internal static string ErrMsg_UnknownSetting(string value) => Format(nameof(ErrMsg_UnknownSetting), value);
internal static string ErrMsg_NotDiscreteSetting(string value) => Format(nameof(ErrMsg_NotDiscreteSetting), value);
internal static string ErrMsg_SelectorMissing => Get(nameof(ErrMsg_SelectorMissing));
internal static string ErrMsg_MonitorNotFoundNumber(string value) => Format(nameof(ErrMsg_MonitorNotFoundNumber), value);
internal static string ErrMsg_MonitorNotFoundId(string value) => Format(nameof(ErrMsg_MonitorNotFoundId), value);
internal static string ErrMsg_NotAdjustable(string setting) => Format(nameof(ErrMsg_NotAdjustable), setting);
internal static string ErrMsg_AdjustValueUnknown(string setting) => Format(nameof(ErrMsg_AdjustValueUnknown), setting);
internal static string ErrMsg_ProfileNotFound(string value) => Format(nameof(ErrMsg_ProfileNotFound), value);
internal static string ErrMsg_UnknownCommand(string value) => Format(nameof(ErrMsg_UnknownCommand), value);
internal static string ErrMsg_InternalError => Get(nameof(ErrMsg_InternalError));
// ---- hints (CLI-generated; some carry a CLI-known list) ----
internal static string Hint_ValidDiscreteSettings(string settings) => Format(nameof(Hint_ValidDiscreteSettings), settings);
internal static string Hint_AdjustSettings(string settings) => Format(nameof(Hint_AdjustSettings), settings);
internal static string Hint_UseSetForAbsolute => Get(nameof(Hint_UseSetForAbsolute));
internal static string Hint_UseHexVcp => Get(nameof(Hint_UseHexVcp));
internal static string Hint_RunList => Get(nameof(Hint_RunList));
internal static string Hint_SelectorMissing => Get(nameof(Hint_SelectorMissing));
internal static string Hint_Orientation => Get(nameof(Hint_Orientation));
internal static string Hint_ConfirmPowerOff => Get(nameof(Hint_ConfirmPowerOff));
internal static string Hint_RunProfiles => Get(nameof(Hint_RunProfiles));
private static string Get(string name) => Manager.GetString(name, CultureInfo.CurrentUICulture) ?? name;
// Defensive formatting: a translator can break a placeholder ({0} -> {1}, an unescaped brace,
// an extra index). That must never crash the CLI or mask the real result. Try the localized
// template; on FormatException fall back to the neutral (English) template we ship and control;
// if even that is malformed, return it unformatted. So a broken translation degrades to English.
private static string Format(string name, params object[] args)
{
var localized = Manager.GetString(name, CultureInfo.CurrentUICulture);
if (localized is not null)
{
try
{
return string.Format(CultureInfo.CurrentCulture, localized, args);
}
catch (FormatException)
{
}
}
var neutral = Manager.GetString(name, CultureInfo.InvariantCulture) ?? name;
return SafeFormat(neutral, args);
}
// Formats with the invariant English template, swallowing a malformed-template FormatException
// by returning the template unformatted. Internal so the no-crash guarantee can be unit-tested.
internal static string SafeFormat(string template, params object[] args)
{
try
{
return string.Format(CultureInfo.InvariantCulture, template, args);
}
catch (FormatException)
{
return template;
}
}
}

View File

@@ -0,0 +1,281 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Text_NoMonitorsDiscovered" xml:space="preserve">
<value>No monitors discovered.</value>
<comment>Text-mode output when list/get finds no monitors.</comment>
</data>
<data name="Text_NotSupported" xml:space="preserve">
<value>(not supported)</value>
<comment>Text-mode marker for an unsupported setting.</comment>
</data>
<data name="Text_Unknown" xml:space="preserve">
<value>(unknown)</value>
<comment>Text-mode marker for a supported setting whose value was not read.</comment>
</data>
<data name="Text_NoProfilesSaved" xml:space="preserve">
<value>No profiles saved.</value>
<comment>profiles command: no saved profiles exist.</comment>
</data>
<data name="Text_NoVcpCapabilities" xml:space="preserve">
<value>No VCP capabilities reported.</value>
</data>
<data name="Text_NoValuesReported" xml:space="preserve">
<value>(no values reported)</value>
<comment>capabilities: a discrete VCP code advertised no enumerated values.</comment>
</data>
<data name="Text_ProfileProcessedBestEffort" xml:space="preserve">
<value>Processed profile '{0}' (best effort).</value>
<comment>{0} = profile name. apply-profile is best-effort and must not claim hardware success; per-setting outcomes are not reported.</comment>
</data>
<data name="Warn_MonitorNumberIgnored" xml:space="preserve">
<value>warning: --monitor-number {0} ignored because --monitor-id was also provided</value>
<comment>{0} = the complete comma-separated list of ignored monitor numbers. Flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_NoSettingSpecified" xml:space="preserve">
<value>no setting specified; pass one of --brightness/--contrast/--volume/--color-temperature/--input-source/--power-state/--orientation</value>
<comment>The flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_OnlyOneSetting" xml:space="preserve">
<value>only one setting may be applied per 'set' call</value>
<comment>'set' is a literal command name and must not be translated.</comment>
</data>
<data name="Hint_OnlyOneSetting" xml:space="preserve">
<value>split into multiple invocations: one --&lt;setting&gt; per call</value>
</data>
<data name="Error_UnknownSetting" xml:space="preserve">
<value>unknown setting '{0}'</value>
<comment>{0} = the setting name the user passed to --setting.</comment>
</data>
<data name="Hint_ValidSettings" xml:space="preserve">
<value>valid settings: {0}</value>
<comment>{0} = comma-separated canonical setting names (CLI syntax, not translated).</comment>
</data>
<data name="Error_TimedOut" xml:space="preserve">
<value>operation timed out after {0}s</value>
<comment>{0} = number of seconds.</comment>
</data>
<data name="Error_Cancelled" xml:space="preserve">
<value>operation was cancelled</value>
</data>
<data name="Error_InvalidArguments" xml:space="preserve">
<value>invalid arguments</value>
</data>
<data name="Error_UnexpectedError" xml:space="preserve">
<value>unexpected error: {0}</value>
<comment>{0} = exception message.</comment>
</data>
<data name="Error_ProviderUnavailable" xml:space="preserve">
<value>PowerDisplay is not running. Enable it in PowerToys settings.</value>
<comment>Shown when the CLI cannot reach the PowerDisplay app over the IPC pipe.</comment>
</data>
<data name="Error_DeserializeMismatch" xml:space="preserve">
<value>Response could not be deserialized as expected type.</value>
<comment>Shown when the app's IPC response does not match the CLI's expected schema (version skew).</comment>
</data>
<data name="Error_NegativeStep" xml:space="preserve">
<value>--step must be &gt;= 0.</value>
<comment>--step is CLI syntax and must not be translated.</comment>
</data>
<data name="Error_NoAdjustSettingSpecified" xml:space="preserve">
<value>no setting specified; pass one of --brightness/--contrast/--volume</value>
<comment>The flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_InvalidMonitorNumber" xml:space="preserve">
<value>invalid monitor number(s) '{0}'; use comma-separated 1-based indices, e.g. 1,2,3</value>
<comment>{0} = the raw value the user passed to -n/--monitor-number. -n/--monitor-number and the example indices are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_SingleMonitorOnly" xml:space="preserve">
<value>this command accepts a single monitor; comma-separated monitor numbers are only supported by set/up/down</value>
<comment>set/up/down are CLI command names and must not be translated.</comment>
</data>
<data name="Label_Error" xml:space="preserve">
<value>Error</value>
<comment>Prefix label for an error line, e.g. "Error: unknown setting foo".</comment>
</data>
<data name="Label_Monitor" xml:space="preserve">
<value>monitor</value>
<comment>Label for the monitor line under an error, e.g. "monitor: 1 (Dell U2720Q)".</comment>
</data>
<data name="Label_Expected" xml:space="preserve">
<value>expected</value>
<comment>Label for the expected-value line under an error.</comment>
</data>
<data name="Label_Supported" xml:space="preserve">
<value>supported</value>
<comment>Label for the supported-values line under an error.</comment>
</data>
<data name="Label_Diagnostic" xml:space="preserve">
<value>diagnostic</value>
<comment>Label for a low-level technical diagnostic line under an error (e.g. a VCP capability reason or a driver error string, shown verbatim in English).</comment>
</data>
<data name="Label_Hint" xml:space="preserve">
<value>hint</value>
<comment>Label for the hint line under an error.</comment>
</data>
<data name="Text_ExpectedInteger" xml:space="preserve">
<value>integer in {0}</value>
<comment>{0} = an inclusive numeric range like "[0, 100]" (not translated). Shown on the "expected" line for a numeric out-of-range error.</comment>
</data>
<data name="ErrMsg_OutOfRange" xml:space="preserve">
<value>{0} is out of range for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name (e.g. brightness). Neither is translated.</comment>
</data>
<data name="ErrMsg_InvalidInteger" xml:space="preserve">
<value>{0} is not a valid integer for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. Neither is translated.</comment>
</data>
<data name="ErrMsg_InvalidDiscrete" xml:space="preserve">
<value>{0} is not a valid value for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. Neither is translated.</comment>
</data>
<data name="ErrMsg_DiscreteNotInSet" xml:space="preserve">
<value>{0} is not in the supported set for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. The supported values are listed on a separate line.</comment>
</data>
<data name="ErrMsg_InvalidOrientation" xml:space="preserve">
<value>{0} is not a valid orientation</value>
<comment>{0} = the value the user passed (not translated).</comment>
</data>
<data name="ErrMsg_Unsupported" xml:space="preserve">
<value>{0} is not supported</value>
<comment>{0} = the setting name (e.g. volume), not translated.</comment>
</data>
<data name="ErrMsg_PowerBlankingConfirm" xml:space="preserve">
<value>this power state blanks the display</value>
</data>
<data name="ErrMsg_HardwareFailure" xml:space="preserve">
<value>hardware write failed</value>
</data>
<data name="ErrMsg_UnknownSetting" xml:space="preserve">
<value>unknown setting {0}</value>
<comment>{0} = the setting name the user passed (not translated).</comment>
</data>
<data name="ErrMsg_NotDiscreteSetting" xml:space="preserve">
<value>{0} is not a discrete setting</value>
<comment>{0} = the setting name the user passed to --setting (not translated).</comment>
</data>
<data name="ErrMsg_SelectorMissing" xml:space="preserve">
<value>a monitor must be specified</value>
</data>
<data name="ErrMsg_MonitorNotFoundNumber" xml:space="preserve">
<value>no monitor found with number {0}</value>
<comment>{0} = the 1-based monitor number the user passed (not translated).</comment>
</data>
<data name="ErrMsg_MonitorNotFoundId" xml:space="preserve">
<value>no monitor found with id {0}</value>
<comment>{0} = the monitor id the user passed (not translated).</comment>
</data>
<data name="ErrMsg_NotAdjustable" xml:space="preserve">
<value>{0} cannot be adjusted relatively</value>
<comment>{0} = the setting name (not translated). Shown for up/down on a non-continuous setting.</comment>
</data>
<data name="ErrMsg_AdjustValueUnknown" xml:space="preserve">
<value>the current {0} value could not be read</value>
<comment>{0} = the setting name (not translated). Shown when up/down cannot read the starting value.</comment>
</data>
<data name="ErrMsg_ProfileNotFound" xml:space="preserve">
<value>no profile with id {0}</value>
<comment>{0} = the profile id</comment>
</data>
<data name="ErrMsg_UnknownCommand" xml:space="preserve">
<value>unknown command {0}</value>
<comment>{0} = the command name (not translated).</comment>
</data>
<data name="ErrMsg_InternalError" xml:space="preserve">
<value>internal error</value>
</data>
<data name="Hint_ValidDiscreteSettings" xml:space="preserve">
<value>valid discrete settings: {0}</value>
<comment>{0} = comma-separated discrete setting names (CLI syntax, not translated).</comment>
</data>
<data name="Hint_AdjustSettings" xml:space="preserve">
<value>relative up/down supports only: {0}</value>
<comment>{0} = comma-separated continuous setting names (CLI syntax, not translated).</comment>
</data>
<data name="Hint_UseSetForAbsolute" xml:space="preserve">
<value>use 'PowerToys.PowerDisplay.Cli.exe set' to assign an absolute value</value>
<comment>'PowerToys.PowerDisplay.Cli.exe set' is a literal command and must not be translated.</comment>
</data>
<data name="Hint_UseHexVcp" xml:space="preserve">
<value>use a hex VCP value (0x??); run 'PowerToys.PowerDisplay.Cli.exe capabilities' to list supported values</value>
<comment>The command and 0x?? are CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_RunList" xml:space="preserve">
<value>run 'PowerToys.PowerDisplay.Cli.exe list' to see available monitors</value>
<comment>'PowerToys.PowerDisplay.Cli.exe list' is a literal command and must not be translated.</comment>
</data>
<data name="Hint_SelectorMissing" xml:space="preserve">
<value>specify --monitor-number/-n or --monitor-id/-i; run 'PowerToys.PowerDisplay.Cli.exe list' to see available monitors</value>
<comment>The option and command names are CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_Orientation" xml:space="preserve">
<value>specify orientation in degrees: 0, 90, 180, or 270</value>
<comment>The degree values must not be translated.</comment>
</data>
<data name="Hint_ConfirmPowerOff" xml:space="preserve">
<value>use --confirm-power-off to allow power states that blank the display</value>
<comment>--confirm-power-off is CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_RunProfiles" xml:space="preserve">
<value>run 'PowerToys.PowerDisplay.Cli.exe profiles' to see available profiles</value>
<comment>'PowerToys.PowerDisplay.Cli.exe profiles' is a literal command and must not be translated.</comment>
</data>
</root>

View File

@@ -0,0 +1,36 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerDisplay.Contracts.UnitTests</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Contracts.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.CodeDom">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,365 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Contracts;
namespace PowerDisplay.Contracts.UnitTests;
[TestClass]
public class RoundTripTests
{
[TestMethod]
public void SetRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "50", ConfirmPowerOff = false },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Set, back!.Command);
Assert.AreEqual(1, back.Set!.MonitorNumber);
Assert.AreEqual("brightness", back.Set.Setting);
Assert.AreEqual("50", back.Set.RawValue);
}
[TestMethod]
public void GetRequest_envelope_round_trips_inherited_selector_fields()
{
// GetRequest/CapabilitiesRequest derive their selector fields from MonitorSelectorRequest;
// verify source-gen serializes the inherited properties on both payload slots.
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Get,
Get = new GetRequest { MonitorNumber = 2, MonitorId = "MON2", SettingFilter = "brightness" },
Capabilities = new CapabilitiesRequest { MonitorNumber = 3, SettingFilter = "input-source" },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(2, back!.Get!.MonitorNumber);
Assert.AreEqual("MON2", back.Get.MonitorId);
Assert.AreEqual("brightness", back.Get.SettingFilter);
Assert.AreEqual(3, back.Capabilities!.MonitorNumber);
Assert.AreEqual("input-source", back.Capabilities.SettingFilter);
}
[TestMethod]
public void ErrorResult_round_trips_and_preserves_exit_code()
{
var error = new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.ProviderUnavailable,
Message = "PowerDisplay is not running.",
Supported = new List<CliSupportedValue>
{
new CliSupportedValue { Name = "DVI", Vcp = "60" },
new CliSupportedValue { Name = "HDMI-1", Vcp = "61" },
},
},
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
};
var json = JsonSerializer.Serialize(error, ContractsJsonContext.Default.CliErrorResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(back);
Assert.AreEqual(CliExitCodes.ProviderUnavailable, back!.Error!.ExitCode);
Assert.AreEqual("PROVIDER_UNAVAILABLE", back.Error.Code);
Assert.IsNotNull(back.Error.Supported);
Assert.AreEqual(2, back.Error.Supported!.Count);
Assert.AreEqual("DVI", back.Error.Supported[0].Name);
Assert.AreEqual("60", back.Error.Supported[0].Vcp);
Assert.AreEqual("HDMI-1", back.Error.Supported[1].Name);
// Discriminator, schema version, and the optional monitor ref must survive the round trip.
Assert.IsTrue(back.IsError);
Assert.AreEqual(CliSchema.Version, back.Version);
Assert.IsNotNull(back.Monitor);
Assert.AreEqual("MON1", back.Monitor!.Id);
Assert.AreEqual("Monitor A", back.Monitor.Name);
// Wire-format compatibility: ExitCode is now a derived (computed) property, but it MUST
// still be serialized for external JSON consumers that read error.exitCode.
StringAssert.Contains(json, "\"exitCode\":10");
}
[TestMethod]
public void ForErrorCode_maps_each_error_code_to_its_matching_exit_code()
{
Assert.AreEqual(CliExitCodes.MonitorNotFound, CliExitCodes.ForErrorCode(CliErrorCodes.MonitorNotFound));
Assert.AreEqual(CliExitCodes.OutOfRange, CliExitCodes.ForErrorCode(CliErrorCodes.OutOfRange));
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, CliExitCodes.ForErrorCode(CliErrorCodes.InvalidDiscreteValue));
Assert.AreEqual(CliExitCodes.UnsupportedFeature, CliExitCodes.ForErrorCode(CliErrorCodes.UnsupportedFeature));
Assert.AreEqual(CliExitCodes.HardwareFailure, CliExitCodes.ForErrorCode(CliErrorCodes.HardwareFailure));
Assert.AreEqual(CliExitCodes.SelectorMissing, CliExitCodes.ForErrorCode(CliErrorCodes.SelectorMissing));
Assert.AreEqual(CliExitCodes.ArgumentError, CliExitCodes.ForErrorCode(CliErrorCodes.ArgumentError));
Assert.AreEqual(CliExitCodes.Timeout, CliExitCodes.ForErrorCode(CliErrorCodes.Timeout));
Assert.AreEqual(CliExitCodes.InternalError, CliExitCodes.ForErrorCode(CliErrorCodes.InternalError));
Assert.AreEqual(CliExitCodes.ProviderUnavailable, CliExitCodes.ForErrorCode(CliErrorCodes.ProviderUnavailable));
// Unknown code degrades to InternalError; and a CliError's ExitCode tracks its Code.
Assert.AreEqual(CliExitCodes.InternalError, CliExitCodes.ForErrorCode("NOT_A_REAL_CODE"));
Assert.AreEqual(CliExitCodes.OutOfRange, new CliError { Code = CliErrorCodes.OutOfRange }.ExitCode);
}
[TestMethod]
public void CliListResult_round_trips_with_nested_monitors()
{
var result = new CliListResult
{
Monitors = new List<CliMonitorRef>
{
new CliMonitorRef
{
Number = 1,
Id = "DISPLAY\\DEL0A8C\\4&1a2b3c4d&0&UID12345",
Name = "Dell U2722D",
Method = "DDC/CI",
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliListResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliListResult);
Assert.IsNotNull(back);
Assert.AreEqual("list", back!.Command);
Assert.AreEqual(1, back.Monitors.Count);
Assert.AreEqual("Dell U2722D", back.Monitors[0].Name);
Assert.AreEqual("DDC/CI", back.Monitors[0].Method);
Assert.IsFalse(back.IsError, "success DTOs carry isError=false");
Assert.AreEqual(CliSchema.Version, back.Version);
}
[TestMethod]
public void CliGetResult_round_trips_with_nested_settings()
{
var result = new CliGetResult
{
Monitors = new List<CliGetMonitorEntry>
{
new CliGetMonitorEntry
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
Settings = new List<CliSettingValue>
{
new CliSettingValue { Setting = "brightness", Display = "75%", Supported = true },
new CliSettingValue { Setting = "contrast", Display = "50%", Supported = true },
new CliSettingValue { Setting = "volume", Supported = false },
},
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliGetResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliGetResult);
Assert.IsNotNull(back);
Assert.AreEqual("get", back!.Command);
Assert.AreEqual(1, back.Monitors.Count);
Assert.AreEqual("MON1", back.Monitors[0].Monitor.Id);
Assert.AreEqual(3, back.Monitors[0].Settings.Count);
Assert.AreEqual("75%", back.Monitors[0].Settings[0].Display);
Assert.IsFalse(back.Monitors[0].Settings[2].Supported);
}
[TestMethod]
public void CliSetResult_round_trips_with_before_after_values()
{
var result = new CliSetResult
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
Setting = "brightness",
BeforeDisplay = "50%",
AfterDisplay = "75%",
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliSetResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(back);
Assert.AreEqual("set", back!.Command);
Assert.AreEqual("brightness", back.Setting);
Assert.AreEqual("50%", back.BeforeDisplay);
Assert.AreEqual("75%", back.AfterDisplay);
Assert.AreEqual("MON1", back.Monitor.Id);
}
[TestMethod]
public void CliCapabilitiesResult_round_trips_with_vcp_codes()
{
var result = new CliCapabilitiesResult
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
CommunicationMethod = "DDC/CI",
RawCapabilities = "(prot(monitor)type(LCD)model(U2722D)cmds(01 02 03 07 0C E3 F3)vcp(02 04 05 08 10 12 14(05 08 0B 0C) 16 18 1A 52 60(01 03 04 0F 11 12) AC AE B6 C0 C6 C8 C9 D6 DF E1 E2 F1 F2 FD)mswhql(1)mccs_ver(2.1))",
Model = "U2722D",
MccsVersion = "2.1",
VcpCodes = new List<CliVcpCodeInfo>
{
new CliVcpCodeInfo { Code = "10", Name = "Luminance", Continuous = true },
new CliVcpCodeInfo { Code = "60", Name = "Input Source", Continuous = false, DiscreteValues = new List<string> { "DP1", "HDMI1" } },
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliCapabilitiesResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliCapabilitiesResult);
Assert.IsNotNull(back);
Assert.AreEqual("capabilities", back!.Command);
Assert.AreEqual("DDC/CI", back.CommunicationMethod);
Assert.AreEqual(result.RawCapabilities, back.RawCapabilities);
Assert.AreEqual("U2722D", back.Model);
Assert.AreEqual("2.1", back.MccsVersion);
Assert.AreEqual(2, back.VcpCodes.Count);
Assert.IsTrue(back.VcpCodes[0].Continuous);
Assert.IsFalse(back.VcpCodes[1].Continuous);
Assert.IsNotNull(back.VcpCodes[1].DiscreteValues);
Assert.AreEqual(2, back.VcpCodes[1].DiscreteValues!.Count);
Assert.AreEqual("DP1", back.VcpCodes[1].DiscreteValues![0]);
}
[TestMethod]
public void CliProfileListResult_round_trips_with_profiles()
{
var result = new CliProfileListResult
{
Profiles = new List<CliProfileInfo>
{
new CliProfileInfo { Name = "Gaming", MonitorCount = 2, LastModified = "2024-01-15T10:30:00Z" },
new CliProfileInfo { Name = "Work", MonitorCount = 1, LastModified = null },
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliProfileListResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliProfileListResult);
Assert.IsNotNull(back);
Assert.AreEqual("profiles", back!.Command);
Assert.AreEqual(2, back.Profiles.Count);
Assert.AreEqual("Gaming", back.Profiles[0].Name);
Assert.AreEqual(2, back.Profiles[0].MonitorCount);
Assert.AreEqual("2024-01-15T10:30:00Z", back.Profiles[0].LastModified);
Assert.AreEqual("Work", back.Profiles[1].Name);
Assert.IsNull(back.Profiles[1].LastModified);
}
[TestMethod]
public void CliApplyProfileResult_round_trips()
{
var result = new CliApplyProfileResult { Profile = "Gaming" };
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliApplyProfileResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(back);
Assert.IsFalse(back!.IsError, "apply-profile is a success envelope (isError=false)");
Assert.AreEqual("apply-profile", back.Command);
Assert.AreEqual("Gaming", back.Profile);
}
[TestMethod]
public void CapabilitiesRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest { MonitorNumber = 1, MonitorId = "MON1" },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Capabilities, back!.Command);
Assert.AreEqual(1, back.Capabilities!.MonitorNumber);
Assert.AreEqual("MON1", back.Capabilities.MonitorId);
}
[TestMethod]
public void ApplyProfileRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 7 },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.ApplyProfile, back!.Command);
Assert.AreEqual(7, back.ApplyProfile!.ProfileId);
}
[TestMethod]
public void AdjustRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Up,
Adjust = new AdjustRequest { MonitorNumber = 2, MonitorId = "MON2", Setting = "brightness", Step = 10 },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Up, back!.Command);
Assert.AreEqual(2, back.Adjust!.MonitorNumber);
Assert.AreEqual("MON2", back.Adjust.MonitorId);
Assert.AreEqual("brightness", back.Adjust.Setting);
Assert.AreEqual(10, back.Adjust.Step);
}
[TestMethod]
public void AdjustRequest_omitted_step_round_trips_as_null()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Down,
Adjust = new AdjustRequest { MonitorNumber = 1, Setting = "contrast", Step = null },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.AreEqual(CliCommandNames.Down, back!.Command);
Assert.IsNull(back.Adjust!.Step, "omitted --step must serialize/deserialize as null so the app applies the settings default");
}
[TestMethod]
public void ApplyProfileRequest_And_ProfileInfo_And_ApplyResult_RoundTripIds()
{
var req = new ApplyProfileRequest { ProfileId = 7 };
var reqBack = JsonSerializer.Deserialize(
JsonSerializer.Serialize(req, ContractsJsonContext.Default.ApplyProfileRequest),
ContractsJsonContext.Default.ApplyProfileRequest);
Assert.AreEqual(7, reqBack!.ProfileId);
var info = new CliProfileInfo { Id = 3, Name = "Gaming", MonitorCount = 2 };
var infoJson = JsonSerializer.Serialize(info, ContractsJsonContext.Default.CliProfileInfo);
Assert.IsTrue(infoJson.Contains("\"id\":3"));
var applied = new CliApplyProfileResult { ProfileId = 3, Profile = "Gaming" };
var appliedBack = JsonSerializer.Deserialize(
JsonSerializer.Serialize(applied, ContractsJsonContext.Default.CliApplyProfileResult),
ContractsJsonContext.Default.CliApplyProfileResult);
Assert.AreEqual(3, appliedBack!.ProfileId);
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
/// <summary>
/// Structured CLI error returned by validators and commands. Mapped 1:1 to the JSON
/// <c>error</c> envelope. <see cref="ExitCode"/> is derived from <see cref="Code"/> via
/// <see cref="CliExitCodes.ForErrorCode"/>, so the two can never disagree; callers set only
/// <see cref="Code"/>.
/// </summary>
public sealed class CliError
{
public string Code { get; init; } = string.Empty;
/// <summary>
/// Stable, fine-grained identifier for the localized message + hint template (e.g.
/// <c>out-of-range</c>, <c>unknown-setting</c>, <c>invalid-integer</c>). Decoupled from
/// <see cref="Code"/>: <see cref="Code"/> is coarse and drives the exit code, while several
/// distinct messages can share one <see cref="Code"/> (e.g. many argument errors are all
/// <c>ARGUMENT_ERROR</c>). The CLI maps this id to a localized template and fills it from the
/// structured fields below. Never localized. Empty falls back to <see cref="Message"/>.
/// </summary>
public string MessageId { get; init; } = string.Empty;
/// <summary>
/// Optional English fallback message. The app leaves this empty and sends only <see cref="Code"/>
/// and <see cref="MessageId"/> plus the structured fields below; the CLI composes the localized,
/// human-readable message from <see cref="MessageId"/> (see <c>Resources</c>). This is populated
/// only as a last-resort fallback for a <see cref="MessageId"/> the CLI does not recognize.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>Process exit code for this error, derived from <see cref="Code"/>. Serialized for
/// JSON consumers; recomputed from <see cref="Code"/> on deserialization.</summary>
public int ExitCode => CliExitCodes.ForErrorCode(Code);
/// <summary>
/// Canonical setting name involved in the error (e.g. <c>brightness</c>, <c>color-temperature</c>).
/// An identifier, never localized; the CLI substitutes it into the localized template for this
/// <see cref="Code"/>. Null when the error is not setting-specific.
/// </summary>
public string? Setting { get; init; }
/// <summary>
/// The offending or selector value as the user supplied it (e.g. <c>150</c>, <c>0x99</c>, a monitor
/// number/id). Data, never localized; the CLI substitutes it into the localized template. Null when
/// the error carries no such value.
/// </summary>
public string? Value { get; init; }
public string? ExpectedRange { get; init; }
public IReadOnlyList<CliSupportedValue>? Supported { get; init; }
/// <summary>
/// Optional technical diagnostic kept verbatim (e.g. a VESA/VCP capability reason or a driver error
/// string). Rendered as-is, not localized: it is low-level hardware jargon aimed at technical users.
/// </summary>
public string? Detail { get; init; }
/// <summary>
/// Optional English fallback hint. Like <see cref="Message"/>, the app normally leaves this empty
/// and the CLI derives the localized hint from <see cref="MessageId"/>; used only as a fallback for
/// an unrecognized <see cref="MessageId"/>.
/// </summary>
public string? Hint { get; init; }
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable error codes emitted as <c>error.code</c> in JSON output.
/// </summary>
public static class CliErrorCodes
{
public const string MonitorNotFound = "MONITOR_NOT_FOUND";
public const string OutOfRange = "OUT_OF_RANGE";
public const string InvalidDiscreteValue = "INVALID_DISCRETE_VALUE";
public const string UnsupportedFeature = "UNSUPPORTED_FEATURE";
public const string HardwareFailure = "HARDWARE_FAILURE";
public const string SelectorMissing = "SELECTOR_MISSING";
public const string ArgumentError = "ARGUMENT_ERROR";
public const string Timeout = "TIMEOUT";
public const string InternalError = "INTERNAL_ERROR";
public const string ProviderUnavailable = "PROVIDER_UNAVAILABLE";
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public static class CliExitCodes
{
public const int Ok = 0;
public const int MonitorNotFound = 1;
public const int OutOfRange = 2;
public const int InvalidDiscreteValue = 3;
public const int UnsupportedFeature = 4;
public const int HardwareFailure = 5;
public const int SelectorMissing = 6;
public const int ArgumentError = 7;
public const int Timeout = 8;
public const int InternalError = 9;
/// <summary>The PowerDisplay app/provider is not running or could not be reached.</summary>
public const int ProviderUnavailable = 10;
/// <summary>
/// Maps a <see cref="CliErrorCodes"/> value to its corresponding process exit code. The two
/// sets are a 1:1 name mirror; this is the single source of that pairing so an error's code and
/// its exit code can never disagree. An unrecognized code maps to <see cref="InternalError"/>.
/// </summary>
public static int ForErrorCode(string errorCode) => errorCode switch
{
CliErrorCodes.MonitorNotFound => MonitorNotFound,
CliErrorCodes.OutOfRange => OutOfRange,
CliErrorCodes.InvalidDiscreteValue => InvalidDiscreteValue,
CliErrorCodes.UnsupportedFeature => UnsupportedFeature,
CliErrorCodes.HardwareFailure => HardwareFailure,
CliErrorCodes.SelectorMissing => SelectorMissing,
CliErrorCodes.ArgumentError => ArgumentError,
CliErrorCodes.Timeout => Timeout,
CliErrorCodes.InternalError => InternalError,
CliErrorCodes.ProviderUnavailable => ProviderUnavailable,
_ => InternalError,
};
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable, fine-grained identifiers for CLI error messages, shared by the app (which stamps one on
/// <see cref="CliError.MessageId"/>) and the CLI (which maps it to a localized template). Decoupled
/// from <see cref="CliErrorCodes"/>: several messages can share one coarse error code / exit code
/// (e.g. many are <see cref="CliErrorCodes.ArgumentError"/>). Never localized; never surfaced to users.
/// </summary>
public static class CliMessageIds
{
// set / common
public const string OutOfRange = "out-of-range";
public const string InvalidInteger = "invalid-integer";
public const string InvalidDiscrete = "invalid-discrete";
public const string DiscreteNotInSet = "discrete-not-in-set";
public const string InvalidOrientation = "invalid-orientation";
public const string Unsupported = "unsupported";
public const string PowerBlankingConfirm = "power-blanking-confirm";
public const string HardwareFailure = "hardware-failure";
// get / capabilities
public const string UnknownSetting = "unknown-setting";
public const string NotDiscreteSetting = "not-discrete-setting";
// monitor resolution
public const string SelectorMissing = "selector-missing";
public const string MonitorNotFoundNumber = "monitor-not-found-number";
public const string MonitorNotFoundId = "monitor-not-found-id";
// up / down
public const string UnknownSettingAdjust = "unknown-setting-adjust";
public const string NotAdjustable = "not-adjustable";
public const string AdjustValueUnknown = "adjust-value-unknown";
// profiles / internal
public const string ProfileNotFound = "profile-not-found";
public const string UnknownCommand = "unknown-command";
public const string InternalError = "internal-error";
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text;
namespace PowerDisplay.Contracts;
/// <summary>
/// Wire-framing constants for the CLI&lt;-&gt;app named pipe, shared by the client and server so the
/// two ends cannot drift. The exchange is one '\n'-delimited request line and one '\n'-delimited
/// response line.
/// </summary>
public static class CliPipeProtocol
{
/// <summary>
/// BOM-less UTF-16 LE. <see cref="Encoding.Unicode"/> emits a BOM on the first write which
/// corrupts line framing on a named pipe; this encoding is identical in every other respect
/// (UTF-16 LE, 2 bytes per ASCII char). Both pipe ends MUST use this exact encoding.
/// </summary>
public static readonly Encoding PipeEncoding = new UnicodeEncoding(bigEndian: false, byteOrderMark: false);
/// <summary>Stream reader/writer buffer size used by both pipe ends.</summary>
public const int BufferSize = 1024;
/// <summary>
/// Maximum length (in characters) the server will accept for a single request line. The
/// protocol carries one short JSON object, so this is a generous sanity bound that prevents an
/// unbounded read from buffering arbitrary amounts of memory in the app process.
/// </summary>
public const int MaxRequestChars = 64 * 1024;
/// <summary>
/// How long the server waits for a connected client to send its request line before abandoning
/// the connection. Without this a client that connects but never sends a line would stall the
/// single-threaded accept loop for every other CLI invocation.
/// </summary>
public const int ReadTimeoutMilliseconds = 10_000;
/// <summary>
/// How long the server waits for the response write and drain (<c>WaitForPipeDrain</c>) to
/// complete before abandoning the connection. Bounds the write phase the same way
/// <see cref="ReadTimeoutMilliseconds"/> bounds the read phase: the pipe uses a 0-byte output
/// buffer, so both the write and the drain block until the client reads, and a connected client
/// that never reads the response would otherwise wedge the single-threaded accept loop
/// indefinitely (<c>WaitForPipeDrain</c> has no timeout/<c>CancellationToken</c> overload).
/// </summary>
public const int WriteTimeoutMilliseconds = 10_000;
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable schema version stamped onto every IPC request and response envelope as informational
/// metadata. NOTE: neither side validates this today — a mismatched CLI/app currently surfaces as
/// a deserialization failure (INTERNAL_ERROR, exit 9), not a dedicated version error, and because
/// the source-gen serializer ignores unknown members, additive ("minor") drift is accepted
/// silently. Version negotiation (rejecting an incompatible major) is intentionally out of scope
/// for v1; wire it up here and in the dispatcher if forward-compat becomes a requirement.
/// </summary>
public static class CliSchema
{
public const string Version = "1.0";
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Canonical setting names accepted by the CLI (the value of <c>--setting</c> and the
/// per-setting <c>--&lt;name&gt;</c> flags). Shared by the CLI argument layer and the app-side
/// executor/projector so the single list cannot drift between the two sides. The same
/// identifiers appear in <see cref="CliSettingValue.Setting"/> so JSON consumers can
/// switch on them.
/// </summary>
public static class CliSettingNames
{
public const string Brightness = "brightness";
public const string Contrast = "contrast";
public const string Volume = "volume";
public const string ColorTemperature = "color-temperature";
public const string InputSource = "input-source";
public const string PowerState = "power-state";
public const string Orientation = "orientation";
/// <summary>All canonical setting names, in canonical (display) order.</summary>
public static readonly string[] All =
[
Brightness,
Contrast,
Volume,
ColorTemperature,
InputSource,
PowerState,
Orientation,
];
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// A discrete-value choice carried in error details so users can self-correct.
/// </summary>
public sealed class CliSupportedValue
{
public string Name { get; init; } = string.Empty;
public string Vcp { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace PowerDisplay.Contracts;
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(CliRequestEnvelope))]
[JsonSerializable(typeof(ApplyProfileRequest))]
[JsonSerializable(typeof(CliListResult))]
[JsonSerializable(typeof(CliGetResult))]
[JsonSerializable(typeof(CliSetResult))]
[JsonSerializable(typeof(CliCapabilitiesResult))]
[JsonSerializable(typeof(CliProfileListResult))]
[JsonSerializable(typeof(CliProfileInfo))]
[JsonSerializable(typeof(CliApplyProfileResult))]
[JsonSerializable(typeof(CliErrorResult))]
[JsonSerializable(typeof(CliResponseHeader))]
public sealed partial class ContractsJsonContext : System.Text.Json.Serialization.JsonSerializerContext
{
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
namespace PowerDisplay.Contracts;
/// <summary>Single source of truth for the CLI&lt;-&gt;app named-pipe name.
/// Session-scoped so concurrent user sessions never collide; the app is single-instance
/// per session (AppInstance), so the session id alone uniquely identifies the server.</summary>
public static class PipeNames
{
// The current process's session id is fixed for the process lifetime, so resolve it once.
// Process.GetCurrentProcess() returns an IDisposable wrapping a native handle; dispose it
// immediately rather than leaking the handle until finalization (CA2000).
private static readonly int SessionId = GetCurrentSessionId();
public static string CliServer()
=> $"PowerDisplay_Cli_Session_{SessionId}";
private static int GetCurrentSessionId()
{
using var process = Process.GetCurrentProcess();
return process.SessionId;
}
}

View File

@@ -0,0 +1,20 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<RootNamespace>PowerDisplay.Contracts</RootNamespace>
<AssemblyName>PowerToys.PowerDisplay.Contracts</AssemblyName>
<Platforms>x64;ARM64</Platforms>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="PowerDisplay.Contracts.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Request for the relative <c>up</c>/<c>down</c> commands. The direction is carried by
/// <see cref="CliRequestEnvelope.Command"/> ("up" or "down"); this payload names the target
/// continuous setting and an optional step.
/// </summary>
public sealed class AdjustRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>One of the continuous setting names: brightness, contrast, volume.</summary>
public string Setting { get; set; } = string.Empty;
/// <summary>Step amount; <see langword="null"/> means "use the mouse_wheel_increment setting".</summary>
public int? Step { get; set; }
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class ApplyProfileRequest
{
public int ProfileId { get; set; }
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Payload for <c>powerdisplay capabilities</c>. See <see cref="MonitorSelectorRequest"/>; the
/// <see cref="MonitorSelectorRequest.SettingFilter"/> restricts the result to a single discrete
/// setting's VCP code (<c>color-temperature</c>, <c>input-source</c>, or <c>power-state</c>).
/// </summary>
public sealed class CapabilitiesRequest : MonitorSelectorRequest
{
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>Canonical command discriminators shared by CLI and app.</summary>
public static class CliCommandNames
{
public const string List = "list";
public const string Get = "get";
public const string Set = "set";
public const string Capabilities = "capabilities";
public const string Profiles = "profiles";
public const string ApplyProfile = "apply-profile";
public const string Up = "up";
public const string Down = "down";
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>Top-level request envelope. Exactly one payload property is non-null,
/// selected by <see cref="Command"/>. Concrete payloads (not polymorphic object) keep AOT happy.</summary>
public sealed class CliRequestEnvelope
{
public string Version { get; set; } = CliSchema.Version;
public string Command { get; set; } = string.Empty;
public GetRequest? Get { get; set; }
public SetRequest? Set { get; set; }
public CapabilitiesRequest? Capabilities { get; set; }
public ApplyProfileRequest? ApplyProfile { get; set; }
public AdjustRequest? Adjust { get; set; }
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>Payload for <c>powerdisplay get</c>. See <see cref="MonitorSelectorRequest"/>.</summary>
public sealed class GetRequest : MonitorSelectorRequest
{
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Shared selector shape for the read commands that target a single monitor and optionally a single
/// setting (<c>get</c>, <c>capabilities</c>). Exactly one of <see cref="MonitorNumber"/> /
/// <see cref="MonitorId"/> identifies the monitor; <see cref="SettingFilter"/> optionally narrows
/// the result to one setting. Concrete subclasses keep the envelope's payload slots distinct types.
/// </summary>
public abstract class MonitorSelectorRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>
/// Optional filter restricting the result to a single setting (e.g. a discrete setting's VCP
/// code for <c>capabilities</c>). Null = no filter.
/// </summary>
public string? SettingFilter { get; set; }
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class SetRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>One of the canonical setting names: brightness, contrast, volume,
/// color-temperature, input-source, power-state, orientation.</summary>
public string Setting { get; set; } = string.Empty;
/// <summary>Raw user-supplied value; the app parses/validates against capabilities.</summary>
public string RawValue { get; set; } = string.Empty;
public bool ConfirmPowerOff { get; set; }
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class CliApplyProfileResult
{
// Response discriminator (see CliResponseHeader): apply-profile is always a success envelope.
// A missing or invalid profile is reported separately as a CliErrorResult, not here.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.ApplyProfile;
public int ProfileId { get; init; }
public string Profile { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
public sealed class CliCapabilitiesResult
{
// Response discriminator (see CliResponseHeader): false on success DTOs, true only on CliErrorResult.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.Capabilities;
public CliMonitorRef Monitor { get; init; } = new();
public string CommunicationMethod { get; init; } = string.Empty;
public string? RawCapabilities { get; init; }
public string? Model { get; init; }
public string? MccsVersion { get; init; }
public IReadOnlyList<CliVcpCodeInfo> VcpCodes { get; init; } = [];
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class CliErrorResult
{
// Response discriminator (see CliResponseHeader): always true on an error envelope.
public bool IsError { get; init; } = true;
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = string.Empty;
public CliError Error { get; init; } = new();
public CliMonitorRef? Monitor { get; init; }
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
/// <summary>
/// One monitor's current-settings block inside a <see cref="CliGetResult"/>. Carries
/// the monitor metadata (number, id, name, transport) alongside its setting values
/// so a single-monitor and an all-monitors get share the same per-entry shape.
/// </summary>
public sealed class CliGetMonitorEntry
{
public CliMonitorRef Monitor { get; init; } = new();
public IReadOnlyList<CliSettingValue> Settings { get; init; } = [];
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
/// <summary>
/// Result envelope of <c>powerdisplay get</c>. Always carries a list — a single-monitor
/// query produces a one-element list; a no-selector query produces one entry per
/// discovered monitor. Consumers always iterate <see cref="Monitors"/>.
/// </summary>
public sealed class CliGetResult
{
// Response discriminator (see CliResponseHeader): false on success DTOs, true only on CliErrorResult.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.Get;
public IReadOnlyList<CliGetMonitorEntry> Monitors { get; init; } = [];
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
public sealed class CliListResult
{
// Response discriminator (see CliResponseHeader): false on success DTOs, true only on CliErrorResult.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.List;
public IReadOnlyList<CliMonitorRef> Monitors { get; init; } = [];
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Compact identification of a monitor used inside every JSON response so
/// consumers can correlate the result back to a single physical device.
/// </summary>
public sealed class CliMonitorRef
{
public int Number { get; init; }
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
/// <summary>
/// Communication transport (<c>DDC/CI</c> for external monitors, <c>WMI</c> for
/// internal panels). Set on the <c>list</c>/<c>get</c>/<c>set</c> envelopes; left
/// <c>null</c> (and omitted from JSON) by <c>capabilities</c>, which carries the
/// transport in its dedicated top-level <c>communicationMethod</c> field instead.
/// </summary>
public string? Method { get; init; }
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// One row in the <c>profiles</c> list: a saved profile's name, how many monitors it
/// targets, and when it was last modified.
/// </summary>
public sealed class CliProfileInfo
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public int MonitorCount { get; init; }
/// <summary>Last-modified timestamp in ISO 8601 round-trip format, or null if unknown.</summary>
public string? LastModified { get; init; }
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
public sealed class CliProfileListResult
{
// Response discriminator (see CliResponseHeader): false on success DTOs, true only on CliErrorResult.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.Profiles;
public IReadOnlyList<CliProfileInfo> Profiles { get; init; } = [];
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
/// <summary>
/// Minimal header the CLI dispatcher deserializes from any IPC response to read the
/// <see cref="IsError"/> discriminator before it knows the concrete result type. Every response
/// carries <c>isError</c>: success DTOs emit <see langword="false"/>, error envelopes
/// (<see cref="CliErrorResult"/>) emit <see langword="true"/>. This makes the success/error split
/// an explicit, app-set field rather than an inference over the response shape.
/// </summary>
public sealed class CliResponseHeader
{
public bool IsError { get; init; }
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class CliSetResult
{
// Response discriminator (see CliResponseHeader): false on success DTOs, true only on CliErrorResult.
public bool IsError { get; init; }
public string Version { get; init; } = CliSchema.Version;
public string Command { get; init; } = CliCommandNames.Set;
public CliMonitorRef Monitor { get; init; } = new();
public string Setting { get; init; } = string.Empty;
public string? BeforeDisplay { get; init; }
public string AfterDisplay { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Contracts;
public sealed class CliSettingValue
{
public string Setting { get; init; } = string.Empty;
/// <summary>
/// Gets the human-readable current value, or <c>null</c> when the monitor does not support the
/// setting or discovery did not read it — so a default/stale field is never reported as a live
/// value. Omitted from JSON when null.
/// </summary>
public string? Display { get; init; }
public bool Supported { get; init; }
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerDisplay.Contracts;
public sealed class CliVcpCodeInfo
{
public string Code { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public bool Continuous { get; init; }
public IReadOnlyList<string>? DiscreteValues { get; init; }
}

View File

@@ -0,0 +1,296 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Unit tests for <see cref="AdjustCommandExecutor"/> (relative up/down on continuous settings).
/// </summary>
[TestClass]
public class AdjustCommandExecutorTests
{
private const int DefaultStep = 5;
private const string StepExpectedRange = "[0, 2147483647]";
private static readonly IReadOnlySet<string> EmptyHidden =
new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
/// <summary>Brightness-capable monitor with the given current value.</summary>
private static Monitor BrightnessMon(int current) => new()
{
Id = "A",
MonitorNumber = 1,
Name = "TestMon",
CommunicationMethod = "DDC/CI",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.Brightness,
CurrentBrightness = current,
};
private sealed class RecordingManager : IMonitorManager
{
public int Calls { get; private set; }
public Task<MonitorOperationResult> SetBrightnessAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetContrastAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetVolumeAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetColorTemperatureAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetInputSourceAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetPowerStateAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
public Task<MonitorOperationResult> SetRotationAsync(string id, int v, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(MonitorOperationResult.Success());
}
}
private static async Task AssertNegativeStepRejectedAsync(int? requestedStep, int defaultStep, int expectedStep)
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = requestedStep };
var manager = new RecordingManager();
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(manager, snapshot, EmptyHidden, req, isUp: true, defaultStep, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
Assert.AreEqual(CliMessageIds.OutOfRange, error.Error.MessageId);
Assert.AreEqual("step", error.Error.Setting);
Assert.AreEqual(expectedStep.ToString(CultureInfo.InvariantCulture), error.Error.Value);
Assert.AreEqual(StepExpectedRange, error.Error.ExpectedRange);
Assert.AreEqual(0, manager.Calls);
Assert.AreNotEqual(CliErrorCodes.HardwareFailure, error.Error.Code);
}
[TestMethod]
public async Task Up_ExplicitNegativeStep_ReturnsOutOfRange_AndSkipsHardware()
=> await AssertNegativeStepRejectedAsync(-1, DefaultStep, -1);
[TestMethod]
public async Task Up_ExplicitIntMinStep_ReturnsOutOfRange_AndSkipsHardware()
=> await AssertNegativeStepRejectedAsync(int.MinValue, DefaultStep, int.MinValue);
[TestMethod]
public async Task Up_NegativeDefaultStep_ReturnsOutOfRange_AndSkipsHardware()
=> await AssertNegativeStepRejectedAsync(null, -1, -1);
[TestMethod]
public async Task Up_AddsStep_AndReportsBeforeAfter()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 20 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual("brightness", result!.Setting);
Assert.AreEqual("50%", result.BeforeDisplay);
Assert.AreEqual("70%", result.AfterDisplay);
Assert.AreEqual("up", result.Command);
}
[TestMethod]
public async Task Up_ClampsToMax100()
{
var snapshot = new List<Monitor> { BrightnessMon(95) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 10 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(error);
Assert.AreEqual("100%", result!.AfterDisplay);
}
[TestMethod]
public async Task Up_HugeStep_ClampsToMax_WithoutOverflow()
{
// A pathologically large step must not overflow `current + delta` (which, computed in int,
// would wrap negative and clamp to 0 — turning an `up` into a slam-to-minimum). It must
// clamp to 100.
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = int.MaxValue };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(error);
Assert.AreEqual("100%", result!.AfterDisplay);
}
[TestMethod]
public async Task Up_CurrentValueUnread_ReturnsHardwareFailure()
{
// The monitor advertises brightness (Supports passes) but discovery never read the live value
// (ReadValues lacks Brightness, so CurrentBrightness is the fabricated default 0). Relative
// adjust must NOT compute from that default and silently write an absolute value; it must
// surface a hardware failure so the caller knows the starting point was unknown.
var monitor = new Monitor
{
Id = "A",
MonitorNumber = 1,
Name = "TestMon",
CommunicationMethod = "DDC/CI",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.None,
CurrentBrightness = 0,
};
var snapshot = new List<Monitor> { monitor };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 10 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.HardwareFailure, error!.Error.Code);
Assert.AreEqual(CliExitCodes.HardwareFailure, error.Error.ExitCode);
}
[TestMethod]
public async Task Down_ClampsToMin0()
{
var snapshot = new List<Monitor> { BrightnessMon(3) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 10 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: false, DefaultStep, default);
Assert.IsNull(error);
Assert.AreEqual("0%", result!.AfterDisplay);
}
[TestMethod]
public async Task NullStep_UsesDefaultStep()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = null };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(error);
Assert.AreEqual("55%", result!.AfterDisplay, "null step must fall back to the supplied default (5)");
}
[TestMethod]
public async Task StepZero_IsNoOp_BeforeEqualsAfter()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 0 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(error);
Assert.AreEqual("50%", result!.BeforeDisplay);
Assert.AreEqual("50%", result.AfterDisplay);
}
[TestMethod]
public async Task UnknownMonitor_ReturnsMonitorNotFound()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 9, Setting = "brightness" };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error!.Error.ExitCode);
}
[TestMethod]
public async Task Brightness_NotSupported_ReturnsUnsupportedFeature()
{
var monitor = new Monitor { Id = "F", MonitorNumber = 6, Name = "NoBrightnessMon", Capabilities = MonitorCapabilities.None };
var snapshot = new List<Monitor> { monitor };
var req = new AdjustRequest { MonitorNumber = 6, Setting = "brightness" };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.UnsupportedFeature, error!.Error.ExitCode);
}
[TestMethod]
public async Task DiscreteSetting_ReturnsUnsupportedFeature()
{
// color-temperature is a known but DISCRETE setting: relative adjust rejects it as UNSUPPORTED
// via the Kind!=Continuous check, which runs BEFORE the Supports check. SupportsColorTemperature
// is deliberately left false: pinning the kind-specific message makes the branch order
// load-bearing — a reorder that ran Supports first would emit the generic "is not supported".
var monitor = new Monitor { Id = "C", MonitorNumber = 3, Name = "ColorMon" };
var snapshot = new List<Monitor> { monitor };
var req = new AdjustRequest { MonitorNumber = 3, Setting = "color-temperature" };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.UnsupportedFeature, error!.Error.Code);
Assert.AreEqual(CliExitCodes.UnsupportedFeature, error.Error.ExitCode);
Assert.AreEqual(CliMessageIds.NotAdjustable, error.Error.MessageId);
}
[TestMethod]
public async Task UnknownSetting_ReturnsArgumentError()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "flicker-rate" };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.ArgumentError, error!.Error.ExitCode);
}
[TestMethod]
public async Task HardwareFailure_ReturnsHardwareFailure()
{
var snapshot = new List<Monitor> { BrightnessMon(50) };
var req = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 10 };
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(new FailingManager(), snapshot, EmptyHidden, req, isUp: true, DefaultStep, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.HardwareFailure, error!.Error.ExitCode);
}
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
using System.Reflection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Invariant guards for the <see cref="CliCommandHandlers"/> registry that replaced the dispatcher's
/// per-command switch. End-to-end command behavior is covered by <see cref="CliRequestDispatcherTests"/>;
/// these pin the registry's shape so a new command cannot be added without a handler, and unknown
/// names cannot silently resolve.
/// </summary>
[TestClass]
public class CliCommandHandlersTests
{
[TestMethod]
public void Registry_ResolvesAHandlerForEveryCommandName()
{
// Reflection over CliCommandNames means adding a command constant without registering a
// handler fails here — the guard the old switch's default arm used to provide implicitly.
var commandNames = typeof(CliCommandNames)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
Assert.AreNotEqual(0, commandNames.Length, "expected CliCommandNames to expose command constants");
foreach (var command in commandNames)
{
Assert.IsTrue(
CliCommandHandlers.TryGet(command, out var handler),
$"no handler registered for command '{command}'");
Assert.IsNotNull(handler, $"handler for '{command}' must not be null");
}
}
[TestMethod]
public void Registry_UnknownCommand_ReturnsFalseAndNullHandler()
{
var found = CliCommandHandlers.TryGet("does-not-exist", out var handler);
Assert.IsFalse(found, "an unrecognized command name must not resolve to a handler");
Assert.IsNull(handler);
}
[TestMethod]
public void Registry_EmptyCommand_ReturnsFalse()
{
Assert.IsFalse(CliCommandHandlers.TryGet(string.Empty, out _));
}
[TestMethod]
public void Registry_LookupIsOrdinalCaseSensitive()
{
// The registry is built with StringComparer.Ordinal to match the canonical constants exactly,
// so a case variant of a real command must not resolve.
Assert.IsFalse(CliCommandHandlers.TryGet("LIST", out _), "lookup must be case-sensitive (ordinal)");
}
[TestMethod]
public void Registry_UpAndDown_ShareTheSameHandlerImplementation()
{
// up and down are both relative-adjust commands that differ only by direction, which the
// handler derives from the envelope command name — so they route to the same implementation.
Assert.IsTrue(CliCommandHandlers.TryGet(CliCommandNames.Up, out var up));
Assert.IsTrue(CliCommandHandlers.TryGet(CliCommandNames.Down, out var down));
Assert.AreEqual(up!.GetType(), down!.GetType());
}
}

View File

@@ -0,0 +1,318 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Ipc;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Unit tests for <see cref="CliPipeServer.ReadBoundedLineAsync"/>, the length-bounded line reader
/// that protects the single-threaded accept loop from oversized / never-terminated requests.
/// </summary>
[TestClass]
public class CliPipeServerTests
{
private static Task<string?> Read(string input, int maxChars = CliPipeProtocolMax)
=> CliPipeServer.ReadBoundedLineAsync(new StringReader(input), maxChars, CancellationToken.None);
private const int CliPipeProtocolMax = 1024;
[TestMethod]
public async Task ReadBoundedLine_NewlineTerminated_ReturnsLineWithoutTerminator()
{
var line = await Read("{\"command\":\"list\"}\n");
Assert.AreEqual("{\"command\":\"list\"}", line);
}
[TestMethod]
public async Task ReadBoundedLine_CrlfTerminated_StripsCarriageReturn()
{
// The client writes via StreamWriter.WriteLineAsync (NewLine = "\r\n" on Windows).
var line = await Read("payload\r\n");
Assert.AreEqual("payload", line);
}
[TestMethod]
public async Task ReadBoundedLine_StopsAtFirstNewline()
{
var line = await Read("first\nsecond\n");
Assert.AreEqual("first", line);
}
[TestMethod]
public async Task ReadBoundedLine_EmptyStream_ReturnsNull()
{
var line = await Read(string.Empty);
Assert.IsNull(line);
}
[TestMethod]
public async Task ReadBoundedLine_UnterminatedTail_ReturnsTail()
{
var line = await Read("no-newline");
Assert.AreEqual("no-newline", line);
}
[TestMethod]
public async Task ReadBoundedLine_AtExactlyMaxChars_IsAllowed()
{
var line = await Read("abcde\n", maxChars: 5);
Assert.AreEqual("abcde", line);
}
[TestMethod]
public async Task ReadBoundedLine_OverMaxChars_Throws()
{
await Assert.ThrowsExceptionAsync<InvalidDataException>(
() => Read("abcdef\n", maxChars: 5));
}
[TestMethod]
public async Task ReadBoundedLine_AlreadyCancelled_Throws()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsExceptionAsync<System.OperationCanceledException>(
() => CliPipeServer.ReadBoundedLineAsync(new StringReader("x\n"), 1024, cts.Token));
}
// ─── CreateServerStream ownership mechanic ────────────────────────────────
// These cover the property the gap-free accept loop relies on: a first instance asserts ownership
// (FirstPipeInstance), a second *overlapping* instance can be created while the first is still
// alive (so the loop can stand up the replacement before disposing the served one, never releasing
// the well-known name), and a second first-instance create is rejected while one is alive (which is
// what surfaces a pre-existing squatter at startup).
private static PipeSecurity CurrentUserPipeSecurity()
{
using var identity = WindowsIdentity.GetCurrent();
var ownerSid = identity.User ?? new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
ownerSid,
PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance,
AccessControlType.Allow));
return security;
}
private static string UniquePipeName() => $"PowerDisplay_Cli_Test_{Guid.NewGuid():N}";
private static readonly string[] DelayThenCreateOrder = { "delay", "create" };
[TestMethod]
public void CreateServerStream_FirstInstance_Succeeds()
{
using var server = CliPipeServer.CreateServerStream(UniquePipeName(), CurrentUserPipeSecurity(), firstInstance: true);
Assert.IsNotNull(server);
}
[TestMethod]
public void CreateServerStream_OverlappingSecondInstance_Succeeds()
{
// The gap-free accept loop creates the replacement instance while the connected one is still
// alive; that overlap MUST be allowed so the well-known name is never released between requests.
var name = UniquePipeName();
var security = CurrentUserPipeSecurity();
using var first = CliPipeServer.CreateServerStream(name, security, firstInstance: true);
using var second = CliPipeServer.CreateServerStream(name, security, firstInstance: false);
Assert.IsNotNull(second);
}
[TestMethod]
public void CreateServerStream_SecondFirstInstance_WhileOneAlive_Throws()
{
// FirstPipeInstance must reject a create when an instance of this name already exists — this is
// exactly what surfaces a pre-existing squatter at startup.
var name = UniquePipeName();
var security = CurrentUserPipeSecurity();
using var first = CliPipeServer.CreateServerStream(name, security, firstInstance: true);
NamedPipeServerStream? second = null;
try
{
second = CliPipeServer.CreateServerStream(name, security, firstInstance: true);
Assert.Fail("A second FirstPipeInstance create must fail while an instance is already alive.");
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
finally
{
second?.Dispose();
}
}
// ─── CreateReplacementWithRetryAsync ───────────────────────────────────────
// The accept loop must not drop the in-flight request or dispose the connected instance just
// because the FIRST replacement-creation attempt (the fast path, tried before serving) throws.
// It must instead retry, with a bounded delay awaited before EVERY attempt (including the first
// one made inside this helper — the fast path already consumed attempt #1), until a replacement
// is created or the caller's token is cancelled (app shutdown) — never spinning without delaying,
// and never delaying-then-attempting out of order.
[TestMethod]
public async Task CreateReplacementWithRetryAsync_FirstAttemptThrows_RetriesAndReturnsReplacement()
{
var attempts = 0;
NamedPipeServerStream? created = null;
var name = UniquePipeName();
var security = CurrentUserPipeSecurity();
NamedPipeServerStream Factory()
{
attempts++;
if (attempts == 1)
{
throw new IOException("simulated first-attempt replacement-creation failure");
}
created = CliPipeServer.CreateServerStream(name, security, firstInstance: true);
return created;
}
var delayCalls = 0;
Task Delay(CancellationToken ct)
{
delayCalls++;
// No-op delay: the test proves retry behaviour, not real timing.
return Task.CompletedTask;
}
try
{
var replacement = await CliPipeServer.CreateReplacementWithRetryAsync(Factory, Delay, CancellationToken.None);
Assert.AreSame(created, replacement);
Assert.AreEqual(2, attempts, "The factory must be retried exactly once after the first failure.");
Assert.AreEqual(2, delayCalls, "The bounded delay must be awaited before every attempt (including the first made in this helper), so two attempts require two delay calls.");
}
finally
{
created?.Dispose();
}
}
[TestMethod]
public async Task CreateReplacementWithRetryAsync_DelaysBeforeFirstAttempt_EvenWhenItSucceeds()
{
// This helper is only reached after the fast-path replacement create has already failed
// once, so even its very first attempt is itself a retry and must be preceded by the
// bounded backoff — not just the attempts after that.
var callOrder = new List<string>();
NamedPipeServerStream? created = null;
var name = UniquePipeName();
var security = CurrentUserPipeSecurity();
NamedPipeServerStream Factory()
{
callOrder.Add("create");
created = CliPipeServer.CreateServerStream(name, security, firstInstance: true);
return created;
}
Task Delay(CancellationToken ct)
{
callOrder.Add("delay");
return Task.CompletedTask;
}
try
{
var replacement = await CliPipeServer.CreateReplacementWithRetryAsync(Factory, Delay, CancellationToken.None);
Assert.AreSame(created, replacement);
CollectionAssert.AreEqual(
DelayThenCreateOrder,
callOrder,
"The delay must be awaited before the create attempt, even when that first attempt succeeds.");
}
finally
{
created?.Dispose();
}
}
[TestMethod]
public async Task CreateReplacementWithRetryAsync_NonRecoverableException_PropagatesWithoutRetry()
{
// Only IOException/UnauthorizedAccessException are recoverable pipe-creation failures worth
// retrying. Anything else (a programming/non-recoverable error) must propagate immediately
// instead of being retried forever.
var attempts = 0;
NamedPipeServerStream Factory()
{
attempts++;
throw new InvalidOperationException("simulated programming/non-recoverable failure");
}
var delayCalls = 0;
Task Delay(CancellationToken ct)
{
delayCalls++;
return Task.CompletedTask;
}
await Assert.ThrowsExceptionAsync<InvalidOperationException>(
() => CliPipeServer.CreateReplacementWithRetryAsync(Factory, Delay, CancellationToken.None));
Assert.AreEqual(1, attempts, "A non-recoverable exception must not be retried.");
Assert.AreEqual(1, delayCalls, "No additional delay must occur once a non-recoverable exception propagates (no retry follows it).");
}
[TestMethod]
public async Task CreateReplacementWithRetryAsync_CancelledDuringRetry_StopsRetryingAndThrows()
{
var attempts = 0;
NamedPipeServerStream Factory()
{
attempts++;
throw new IOException("simulated persistent replacement-creation failure");
}
using var cts = new CancellationTokenSource();
var delayCalls = 0;
Task Delay(CancellationToken ct)
{
delayCalls++;
if (delayCalls == 2)
{
// Simulate app shutdown happening while the retry loop is backing off before the
// second attempt (the delay before the first attempt already ran normally).
cts.Cancel();
return Task.Delay(Timeout.Infinite, ct);
}
return Task.CompletedTask;
}
// Task.Delay surfaces cancellation as TaskCanceledException, a subtype of
// OperationCanceledException; assert on the base type the same way the accept loop's
// `catch (OperationCanceledException)` does, rather than the exact derived type.
var threwOperationCanceled = false;
try
{
await CliPipeServer.CreateReplacementWithRetryAsync(Factory, Delay, cts.Token);
}
catch (OperationCanceledException)
{
threwOperationCanceled = true;
}
Assert.IsTrue(threwOperationCanceled, "Cancellation during the retry delay must propagate as OperationCanceledException.");
Assert.AreEqual(1, attempts, "Retry must stop as soon as cancellation is observed, not keep spinning.");
}
}

View File

@@ -0,0 +1,545 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Models;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Unit tests for <see cref="CliRequestDispatcher.BuildResponseAsync"/>.
/// Tests drive the dispatcher directly, without the WinUI host adapter.
/// </summary>
[TestClass]
public class CliRequestDispatcherTests
{
// ─── Shared fixtures ──────────────────────────────────────────────────────
private static readonly IReadOnlySet<string> NoHidden =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private static readonly PowerDisplayProfiles EmptyProfiles =
new PowerDisplayProfiles { Profiles = new List<PowerDisplayProfile>() };
private static Monitor MakeMon(int number = 1, string id = "A", string name = "Mon A")
=> new()
{
Id = id,
MonitorNumber = number,
Name = name,
CommunicationMethod = "DDC/CI",
GdiDeviceName = @"\\.\DISPLAY1",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.Brightness,
CurrentBrightness = 50,
};
/// <summary>
/// Builds a minimal <see cref="CliRequestEnvelope"/> for the given command.
/// </summary>
private static CliRequestEnvelope MakeEnvelope(string command) => new() { Command = command };
/// <summary>
/// Calls <c>BuildResponseAsync</c> with the int-based apply-profile delegate signature.
/// The delegate returns the resolved profile's name (<see langword="null"/> means "not found"),
/// so the apply-profile handler never needs to fall back to <c>LoadProfiles</c>.
/// </summary>
private static Task<string> Dispatch(
CliRequestEnvelope envelope,
IReadOnlyList<Monitor>? monitors = null,
PowerDisplayProfiles? profiles = null,
Func<int, CancellationToken, Task<string?>>? applyProfile = null,
int defaultStep = 5,
Func<CancellationToken, Task<PowerDisplayProfiles>>? loadProfilesAsync = null,
IReadOnlyList<CustomVcpValueMapping>? customMappings = null,
CancellationToken ct = default)
{
return CliRequestDispatcher.BuildResponseAsync(
envelope,
monitors ?? new[] { MakeMon() },
NoHidden,
customMappings ?? Array.Empty<CustomVcpValueMapping>(),
new NoOpManager(),
defaultStep,
loadProfilesAsync ?? (_ => Task.FromResult(profiles ?? EmptyProfiles)),
applyProfile ?? ((_, _) => Task.FromResult<string?>("Default")),
ct);
}
// ─── list command ─────────────────────────────────────────────────────────
[TestMethod]
public async Task List_ReturnsCliListResult()
{
var envelope = MakeEnvelope(CliCommandNames.List);
var json = await Dispatch(envelope, monitors: new[] { MakeMon(1, "A") });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliListResult);
Assert.IsNotNull(result, "should deserialize to CliListResult");
Assert.AreEqual("list", result.Command);
Assert.AreEqual(1, result.Monitors.Count);
}
// ─── get command ──────────────────────────────────────────────────────────
[TestMethod]
public async Task Get_NoSelector_ReturnsAllMonitors()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Get,
Get = new GetRequest { MonitorNumber = null, MonitorId = null },
};
var json = await Dispatch(envelope, monitors: new[] { MakeMon(1, "A"), MakeMon(2, "B") });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliGetResult);
Assert.IsNotNull(result, "should deserialize to CliGetResult");
Assert.AreEqual("get", result.Command);
Assert.AreEqual(2, result.Monitors.Count);
}
[TestMethod]
public async Task Get_UnknownMonitorNumber_ReturnsErrorResult()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Get,
Get = new GetRequest { MonitorNumber = 99 },
};
var json = await Dispatch(envelope, monitors: new[] { MakeMon(1, "A") });
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error.Error.Code);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error.Error.ExitCode);
}
// ─── set command ──────────────────────────────────────────────────────────
[TestMethod]
public async Task Set_ValidBrightness_ReturnsCliSetResult()
{
var mon = MakeMon(1, "A");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest
{
MonitorNumber = 1,
Setting = "brightness",
RawValue = "75",
},
};
var json = await Dispatch(envelope, monitors: new[] { mon });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(result, "should deserialize to CliSetResult");
Assert.AreEqual("set", result.Command);
// Pin the dispatch wiring end-to-end: MakeMon's CurrentBrightness=50 → BeforeDisplay; the
// requested 75 → AfterDisplay. (Command alone is satisfied by the DTO default and carries no
// signal.) A handler that passed a wrong value to the executor, or swapped before/after, fails here.
Assert.AreEqual("50%", result.BeforeDisplay);
Assert.AreEqual("75%", result.AfterDisplay);
}
[TestMethod]
public async Task Set_DiscreteValue_UsesMonitorSpecificCustomName()
{
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11, 0x12 });
var mon = MakeMon(1, "A");
mon.VcpCapabilitiesInfo = caps;
mon.ReadValues = MonitorReadFlags.InputSource;
mon.CurrentInputSource = 0x11;
var mappings = new List<CustomVcpValueMapping>
{
new() { VcpCode = 0x60, Value = 0x11, CustomName = "Work laptop", ApplyToAll = false, TargetMonitorId = "A" },
new() { VcpCode = 0x60, Value = 0x12, CustomName = "Game console", ApplyToAll = false, TargetMonitorId = "A" },
new() { VcpCode = 0x60, Value = 0x12, CustomName = "Wrong monitor", ApplyToAll = false, TargetMonitorId = "B" },
};
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest { MonitorNumber = 1, Setting = CliSettingNames.InputSource, RawValue = "0x12" },
};
var json = await Dispatch(envelope, monitors: new[] { mon }, customMappings: mappings);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(result);
Assert.AreEqual("Work laptop (0x11)", result.BeforeDisplay);
Assert.AreEqual("Game console (0x12)", result.AfterDisplay);
}
[TestMethod]
public async Task Set_MissingPayload_ReturnsErrorResult()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = null,
};
var json = await Dispatch(envelope);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
}
[TestMethod]
public async Task Set_ServerTokenCancelled_ReturnsTimeoutError()
{
// This test injects a cancelled server-side token directly into BuildResponseAsync so the
// handler observes server-lifetime cancellation and maps it to TIMEOUT (exit 8).
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest
{
MonitorNumber = 1,
Setting = "brightness",
RawValue = "75",
},
};
using var cts = new CancellationTokenSource();
cts.Cancel();
var json = await Dispatch(envelope, monitors: new[] { MakeMon() }, ct: cts.Token);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.Timeout, error.Error.Code);
Assert.AreEqual(CliExitCodes.Timeout, error.Error.ExitCode);
}
// ─── capabilities command ─────────────────────────────────────────────────
[TestMethod]
public async Task Capabilities_SelectorMissing_ReturnsErrorResult()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest { MonitorNumber = null, MonitorId = null },
};
var json = await Dispatch(envelope);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.SelectorMissing, error.Error.Code);
}
[TestMethod]
public async Task Capabilities_ValidSelector_ReturnsCliCapabilitiesResult()
{
var mon = MakeMon(1, "A");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest { MonitorNumber = 1 },
};
var json = await Dispatch(envelope, monitors: new[] { mon });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliCapabilitiesResult);
Assert.IsNotNull(result, "should deserialize to CliCapabilitiesResult");
Assert.AreEqual("capabilities", result.Command);
// Confirm the dispatch path resolved the right monitor and carried the top-level transport,
// not just a typed-but-empty envelope: MakeMon(1, "A") is monitor #1 on DDC/CI.
Assert.AreEqual(1, result.Monitor.Number);
Assert.AreEqual("DDC/CI", result.CommunicationMethod);
}
[TestMethod]
public async Task Capabilities_WithSettingFilter_ReturnsOnlyMatchingCode()
{
var mon = MakeMon(1, "A");
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x14] = new VcpCodeInfo(0x14, "Color Preset", new List<int> { 0x05 });
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11 });
mon.VcpCapabilitiesInfo = caps;
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest { MonitorNumber = 1, SettingFilter = "input-source" },
};
var json = await Dispatch(envelope, monitors: new[] { mon });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliCapabilitiesResult);
Assert.IsNotNull(result);
Assert.AreEqual(1, result!.VcpCodes.Count);
Assert.AreEqual("0x60", result.VcpCodes[0].Code);
}
// ─── profiles command ─────────────────────────────────────────────────────
[TestMethod]
public async Task Profiles_ReturnsCliProfileListResult()
{
var profiles = new PowerDisplayProfiles
{
Profiles = new List<PowerDisplayProfile>
{
new PowerDisplayProfile { Name = "Night", MonitorSettings = new List<ProfileMonitorSetting>(), Id = 1 },
},
};
var envelope = MakeEnvelope(CliCommandNames.Profiles);
var json = await Dispatch(envelope, profiles: profiles);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliProfileListResult);
Assert.IsNotNull(result, "should deserialize to CliProfileListResult");
Assert.AreEqual("profiles", result.Command);
Assert.AreEqual(1, result.Profiles.Count);
Assert.AreEqual("Night", result.Profiles[0].Name);
}
[TestMethod]
public async Task Profiles_HidesProfilesWithoutAssignedId()
{
var profiles = new PowerDisplayProfiles
{
Profiles = new List<PowerDisplayProfile>
{
new PowerDisplayProfile { Name = "Legacy", MonitorSettings = new List<ProfileMonitorSetting>() },
new PowerDisplayProfile { Name = "Assigned", MonitorSettings = new List<ProfileMonitorSetting>(), Id = 2 },
},
};
var json = await Dispatch(MakeEnvelope(CliCommandNames.Profiles), profiles: profiles);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliProfileListResult);
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Profiles.Count);
Assert.AreEqual(2, result.Profiles[0].Id);
}
// ─── apply-profile command ────────────────────────────────────────────────
[TestMethod]
public async Task ApplyProfile_FoundProfile_ReturnsCliApplyProfileResult()
{
// The apply delegate returns the resolved name directly; the handler must use it as-is.
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>("Night");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 1 },
};
var json = await Dispatch(envelope, applyProfile: applyFn);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(result, "should deserialize to CliApplyProfileResult");
Assert.AreEqual("apply-profile", result.Command);
Assert.AreEqual(1, result.ProfileId);
Assert.AreEqual("Night", result.Profile);
}
[TestMethod]
public async Task ApplyProfile_FoundProfile_NeverCallsLoadProfiles()
{
// Regression test for the duplicate-load bug: once the apply delegate has resolved and
// applied the profile, the handler must use the returned name directly and must never call
// LoadProfilesAsync again to "recover" the name (extra disk I/O, and can report a
// stale/renamed/deleted name if the profile changed between the two loads).
var loadProfilesCallCount = 0;
Func<CancellationToken, Task<PowerDisplayProfiles>> loadProfilesAsync = _ =>
{
loadProfilesCallCount++;
throw new InvalidOperationException("LoadProfilesAsync must not be called by apply-profile");
};
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>("Night");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 1 },
};
var json = await Dispatch(envelope, applyProfile: applyFn, loadProfilesAsync: loadProfilesAsync);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(result, "should deserialize to CliApplyProfileResult");
Assert.AreEqual("Night", result.Profile);
Assert.AreEqual(0, loadProfilesCallCount, "apply-profile must not call LoadProfilesAsync");
}
[TestMethod]
public async Task ApplyProfile_ProfileNotFound_ReturnsArgumentError()
{
// null = profile not found (unknown/invalid id)
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>(null);
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 99 },
};
var json = await Dispatch(envelope, applyProfile: applyFn);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
Assert.AreEqual("apply-profile", error.Command);
Assert.AreEqual(CliMessageIds.ProfileNotFound, error.Error.MessageId);
Assert.AreEqual("99", error.Error.Value);
}
[TestMethod]
public async Task ApplyProfile_EmptyProfileName_ReturnsArgumentError()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 0 },
};
var json = await Dispatch(envelope);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
}
// ─── apply-profile by id ──────────────────────────────────────────────────
[TestMethod]
public async Task ApplyProfile_UnknownId_ReturnsArgumentError()
{
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>(null);
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 99 },
};
var json = await Dispatch(envelope, applyProfile: applyFn);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
Assert.AreEqual(CliMessageIds.ProfileNotFound, error.Error.MessageId);
Assert.AreEqual("99", error.Error.Value);
}
[TestMethod]
public async Task ApplyProfile_NonPositiveId_ReturnsArgumentError()
{
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>("Should not be called");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 0 },
};
var json = await Dispatch(envelope, applyProfile: applyFn);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
}
[TestMethod]
public async Task ApplyProfile_FoundId_ReturnsSuccessWithId()
{
Func<int, CancellationToken, Task<string?>> applyFn = (id, ct) => Task.FromResult<string?>("Gaming");
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileId = 3 },
};
var json = await Dispatch(envelope, applyProfile: applyFn);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(result, "should deserialize to CliApplyProfileResult");
Assert.AreEqual("apply-profile", result.Command);
Assert.AreEqual(3, result.ProfileId);
Assert.AreEqual("Gaming", result.Profile);
}
// ─── up / down commands ───────────────────────────────────────────────────
[TestMethod]
public async Task Up_Brightness_ReturnsCliSetResult_WithIncrementedValue()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Up,
Adjust = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = 10 },
};
var json = await Dispatch(envelope, monitors: new[] { MakeMon() });
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(result, "should deserialize to CliSetResult");
Assert.AreEqual("up", result!.Command);
Assert.AreEqual("60%", result.AfterDisplay);
}
[TestMethod]
public async Task Down_NullStep_UsesDefaultStep()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Down,
Adjust = new AdjustRequest { MonitorNumber = 1, Setting = "brightness", Step = null },
};
var json = await Dispatch(envelope, monitors: new[] { MakeMon() }, defaultStep: 5);
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(result);
Assert.AreEqual("45%", result!.AfterDisplay, "50 - default step 5 = 45");
}
[TestMethod]
public async Task Up_MissingAdjustPayload_ReturnsArgumentError()
{
var envelope = new CliRequestEnvelope { Command = CliCommandNames.Up, Adjust = null };
var json = await Dispatch(envelope);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
}
// ─── unknown command ──────────────────────────────────────────────────────
[TestMethod]
public async Task UnknownCommand_ReturnsArgumentError()
{
// A command name the app does not recognize (e.g. a newer CLI talking to an older app) is a
// bad argument, not an internal fault: it maps to ARGUMENT_ERROR (exit 7), not INTERNAL_ERROR
// (exit 9). The offending command name is echoed back in the Command field.
var envelope = MakeEnvelope("does-not-exist");
var json = await Dispatch(envelope);
var error = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(error, "should deserialize to CliErrorResult");
Assert.AreEqual(CliErrorCodes.ArgumentError, error.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
Assert.AreEqual("does-not-exist", error.Command);
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Invariant guards for <see cref="CliSettingCatalog"/> — the single source of per-setting VCP
/// metadata. These pin the catalog's shape so the read/write call sites that consume it cannot
/// silently drift.
/// </summary>
[TestClass]
public class CliSettingCatalogTests
{
[TestMethod]
public void Catalog_CoversTheSixVcpSettings_InCanonicalOrder()
{
var names = CliSettingCatalog.VcpSettings.Select(s => s.Name).ToArray();
CollectionAssert.AreEqual(
new[]
{
CliSettingNames.Brightness,
CliSettingNames.Contrast,
CliSettingNames.Volume,
CliSettingNames.ColorTemperature,
CliSettingNames.InputSource,
CliSettingNames.PowerState,
},
names);
}
[TestMethod]
public void Catalog_ExcludesOrientation()
{
// Orientation is GDI-based, not a VCP setting, so it must not be in the VCP catalog.
Assert.IsNull(CliSettingCatalog.TryGet(CliSettingNames.Orientation));
Assert.IsFalse(CliSettingCatalog.VcpSettings.Any(s => s.Name == CliSettingNames.Orientation));
}
[TestMethod]
public void Catalog_TryGet_ReturnsNullForUnknownName()
{
Assert.IsNull(CliSettingCatalog.TryGet("does-not-exist"));
}
[TestMethod]
public void Catalog_ClassifiesContinuousAndDiscreteSettings()
{
Assert.AreEqual(CliSettingKind.Continuous, CliSettingCatalog.TryGet(CliSettingNames.Brightness)!.Kind);
Assert.AreEqual(CliSettingKind.Continuous, CliSettingCatalog.TryGet(CliSettingNames.Contrast)!.Kind);
Assert.AreEqual(CliSettingKind.Continuous, CliSettingCatalog.TryGet(CliSettingNames.Volume)!.Kind);
Assert.AreEqual(CliSettingKind.Discrete, CliSettingCatalog.TryGet(CliSettingNames.ColorTemperature)!.Kind);
Assert.AreEqual(CliSettingKind.Discrete, CliSettingCatalog.TryGet(CliSettingNames.InputSource)!.Kind);
Assert.AreEqual(CliSettingKind.Discrete, CliSettingCatalog.TryGet(CliSettingNames.PowerState)!.Kind);
}
[TestMethod]
public void Catalog_OnlyPowerStateBlanksDisplay()
{
// Only power-state can blank the panel, so it is the only setting that gates --confirm-power-off.
Assert.IsTrue(CliSettingCatalog.TryGet(CliSettingNames.PowerState)!.BlanksDisplay);
foreach (var setting in CliSettingCatalog.VcpSettings.Where(s => s.Name != CliSettingNames.PowerState))
{
Assert.IsFalse(setting.BlanksDisplay, $"{setting.Name} must not blank the display");
}
}
[TestMethod]
public void Catalog_ContinuousSettingsHaveNoDiscreteSupportedValues()
{
var monitor = new Monitor();
foreach (var setting in CliSettingCatalog.VcpSettings.Where(s => s.Kind == CliSettingKind.Continuous))
{
Assert.IsNull(setting.SupportedValues(monitor), $"{setting.Name} is continuous and has no discrete value set");
}
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Ipc;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Tests for <see cref="CliSettingValidation"/> — the single source of the discrete supported-value
/// rule shared by the <c>set</c> command and the <c>apply-profile</c> outcomes path.
/// </summary>
[TestClass]
public class CliSettingValidationTests
{
private static readonly int[] SupportedSet = { 0x01, 0x05, 0x08 };
[TestMethod]
public void IsDiscreteValueSupported_NullSet_AcceptsAnyValue()
{
// No advertised set → the hardware write is the final arbiter, so accept.
Assert.IsTrue(CliSettingValidation.IsDiscreteValueSupported(0x99, null));
}
[TestMethod]
public void IsDiscreteValueSupported_EmptySet_AcceptsAnyValue()
{
Assert.IsTrue(CliSettingValidation.IsDiscreteValueSupported(0x99, Array.Empty<int>()));
}
[TestMethod]
public void IsDiscreteValueSupported_ValueInSet_ReturnsTrue()
{
Assert.IsTrue(CliSettingValidation.IsDiscreteValueSupported(0x05, SupportedSet));
}
[TestMethod]
public void IsDiscreteValueSupported_ValueNotInSet_ReturnsFalse()
{
Assert.IsFalse(CliSettingValidation.IsDiscreteValueSupported(0x99, SupportedSet));
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Shared fake <see cref="IMonitorManager"/> whose every write fails with a configurable message.
/// Used by the executor tests that exercise the HARDWARE_FAILURE path.
/// </summary>
internal sealed class FailingManager : IMonitorManager
{
private readonly string _errorMessage;
public FailingManager(string errorMessage = "simulated hardware failure")
{
_errorMessage = errorMessage;
}
public Task<MonitorOperationResult> SetBrightnessAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetContrastAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetVolumeAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetColorTemperatureAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetInputSourceAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetPowerStateAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
public Task<MonitorOperationResult> SetRotationAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
}

View File

@@ -0,0 +1,565 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Models;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc.UnitTests;
[TestClass]
public class MonitorDtoProjectorTests
{
// ─── Helpers ──────────────────────────────────────────────────────────────
private static readonly IReadOnlySet<string> EmptyHidden =
new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
/// <summary>Creates a minimal monitor with brightness support and a GDI device name.</summary>
private static Monitor MakeMon(int number, string id, string name = "TestMon", string gdi = @"\\.\DISPLAY1")
=> new()
{
MonitorNumber = number,
Id = id,
Name = name,
CommunicationMethod = "DDC/CI",
GdiDeviceName = gdi,
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.Brightness | MonitorReadFlags.Orientation,
CurrentBrightness = 42,
};
// ─── ExcludeHidden ────────────────────────────────────────────────────────
[TestMethod]
public void BuildListResult_ExcludesHiddenMonitors()
{
var monitors = new List<Monitor>
{
new() { Id = "A", MonitorNumber = 1, Name = "Mon A", Capabilities = MonitorCapabilities.Brightness },
new() { Id = "B", MonitorNumber = 2, Name = "Mon B", Capabilities = MonitorCapabilities.Brightness },
};
var hidden = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase) { "B" };
var result = MonitorDtoProjector.BuildListResult(monitors, hidden);
Assert.AreEqual(1, result.Monitors.Count);
Assert.AreEqual("A", result.Monitors[0].Id);
}
[TestMethod]
public void BuildListResult_AllHidden_ReturnsEmptyList()
{
var monitors = new List<Monitor>
{
new() { Id = "A", MonitorNumber = 1 },
new() { Id = "B", MonitorNumber = 2 },
};
var hidden = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase) { "A", "B" };
var result = MonitorDtoProjector.BuildListResult(monitors, hidden);
Assert.AreEqual(0, result.Monitors.Count);
}
[TestMethod]
public void BuildListResult_NoneHidden_ReturnsAll()
{
var monitors = new List<Monitor>
{
MakeMon(1, "A"),
MakeMon(2, "B"),
};
var result = MonitorDtoProjector.BuildListResult(monitors, EmptyHidden);
Assert.AreEqual(2, result.Monitors.Count);
}
// ─── List entry projection ────────────────────────────────────────────────
[TestMethod]
public void BuildListResult_EntryCopiesMonitorFields()
{
var monitor = new Monitor
{
MonitorNumber = 3,
Id = "MON-3",
Name = "Dell U2722D",
CommunicationMethod = "DDC/CI",
GdiDeviceName = @"\\.\DISPLAY3",
Capabilities = MonitorCapabilities.Brightness | MonitorCapabilities.Contrast,
};
var result = MonitorDtoProjector.BuildListResult(new List<Monitor> { monitor }, EmptyHidden);
var entry = result.Monitors[0];
Assert.AreEqual(3, entry.Number);
Assert.AreEqual("MON-3", entry.Id);
Assert.AreEqual("Dell U2722D", entry.Name);
Assert.AreEqual("DDC/CI", entry.Method);
}
// ─── BuildGetResult — no selector path ───────────────────────────────────
[TestMethod]
public void BuildGetResult_NoSelector_ReturnsAllVisibleMonitors()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: null);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual(2, result!.Monitors.Count);
}
[TestMethod]
public void BuildGetResult_NoSelector_UnknownSetting_YieldsArgumentError()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "bogus");
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
Assert.IsNull(error.Monitor);
}
[TestMethod]
public void BuildGetResult_NoSelector_TrulyUnknownSetting_MessageContainsOriginalCase()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (_, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "BRIGHTNESSS");
Assert.IsNotNull(error);
Assert.AreEqual(CliMessageIds.UnknownSetting, error!.Error.MessageId);
Assert.AreEqual("BRIGHTNESSS", error.Error.Value);
}
// ─── BuildGetResult — selected path ──────────────────────────────────────
[TestMethod]
public void BuildGetResult_UnknownMonitorNumber_YieldsMonitorNotFound()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: 9, id: null, settingFilter: null);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error!.Error.Code);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error.Error.ExitCode);
}
[TestMethod]
public void BuildGetResult_UnknownMonitorId_YieldsMonitorNotFound()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: "Z", settingFilter: null);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error!.Error.Code);
}
[TestMethod]
public void BuildGetResult_ByNumber_ReturnsOneEntryForThatMonitor()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: 2, id: null, settingFilter: null);
Assert.IsNull(error);
Assert.AreEqual(1, result!.Monitors.Count);
Assert.AreEqual(2, result.Monitors[0].Monitor.Number);
}
[TestMethod]
public void BuildGetResult_ById_ReturnsOneEntryForThatMonitor()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: "B", settingFilter: null);
Assert.IsNull(error);
Assert.AreEqual(1, result!.Monitors.Count);
Assert.AreEqual("B", result.Monitors[0].Monitor.Id);
}
[TestMethod]
public void BuildGetResult_HiddenMonitorTargeted_ReturnsMonitorNotFound()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var hidden = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase) { "A" };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, hidden, number: 1, id: null, settingFilter: null);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error!.Error.ExitCode);
}
[TestMethod]
public void BuildGetResult_BothSelectors_IdWins()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: 1, id: "B", settingFilter: null);
Assert.IsNull(error);
Assert.AreEqual("B", result!.Monitors[0].Monitor.Id);
}
// ─── BuildGetResult — setting projection ─────────────────────────────────
[TestMethod]
public void BuildGetResult_AllSettingsPresent_CountMatchesAllSettingNames()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, _) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: null);
Assert.AreEqual(CliSettingNames.All.Length, result!.Monitors[0].Settings.Count);
}
[TestMethod]
public void BuildGetResult_BrightnessSupported_DisplayIsPercentageString()
{
var monitor = MakeMon(1, "A");
monitor.CurrentBrightness = 75;
var monitors = new List<Monitor> { monitor };
var (result, _) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "brightness");
var setting = result!.Monitors[0].Settings[0];
Assert.AreEqual("brightness", setting.Setting);
Assert.IsTrue(setting.Supported);
Assert.AreEqual("75%", setting.Display);
}
[TestMethod]
public void BuildGetResult_SupportedButUnread_OmitsDisplay()
{
var monitor = new Monitor
{
MonitorNumber = 1,
Id = "A",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.None, // supported but not read
CurrentBrightness = 50,
};
var monitors = new List<Monitor> { monitor };
var (result, _) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "brightness");
var setting = result!.Monitors[0].Settings[0];
Assert.IsTrue(setting.Supported);
Assert.IsNull(setting.Display);
}
[TestMethod]
public void BuildGetResult_UnsupportedSetting_SupportedFalseAndNullValue()
{
// Contrast is not in MonitorCapabilities.Brightness
var monitor = new Monitor
{
MonitorNumber = 1,
Id = "A",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.Brightness,
};
var monitors = new List<Monitor> { monitor };
var (result, _) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "contrast");
var setting = result!.Monitors[0].Settings[0];
Assert.IsFalse(setting.Supported);
Assert.IsNull(setting.Display);
}
[TestMethod]
public void BuildGetResult_OrientationDisplay_IsDegreesNotIndex()
{
var monitor = MakeMon(1, "A");
monitor.Orientation = 1; // index 1 = 90 degrees
var monitors = new List<Monitor> { monitor };
var (result, _) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "orientation");
var setting = result!.Monitors[0].Settings[0];
Assert.AreEqual("orientation", setting.Setting);
Assert.AreEqual("90°", setting.Display);
}
[TestMethod]
public void BuildGetResult_SettingFilterIsCaseInsensitive()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildGetResult(monitors, EmptyHidden, number: null, id: null, settingFilter: "Brightness");
Assert.IsNull(error);
Assert.AreEqual(1, result!.Monitors[0].Settings.Count);
Assert.AreEqual("brightness", result.Monitors[0].Settings[0].Setting);
}
// ─── BuildCapabilitiesResult ──────────────────────────────────────────────
[TestMethod]
public void BuildCapabilitiesResult_NoSelector_YieldsSelectorMissing()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(monitors, EmptyHidden, number: null, id: null);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.SelectorMissing, error!.Error.Code);
Assert.AreEqual(CliExitCodes.SelectorMissing, error.Error.ExitCode);
}
[TestMethod]
public void BuildCapabilitiesResult_UnknownMonitorNumber_YieldsMonitorNotFound()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(monitors, EmptyHidden, number: 9, id: null);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error!.Error.Code);
}
[TestMethod]
public void BuildCapabilitiesResult_NoVcpCaps_ReturnsEmptyVcpCodes()
{
var monitor = MakeMon(1, "A");
monitor.VcpCapabilitiesInfo = null;
var monitors = new List<Monitor> { monitor };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(monitors, EmptyHidden, number: 1, id: null);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual(0, result!.VcpCodes.Count);
}
[TestMethod]
public void BuildCapabilitiesResult_MethodGoesInTopLevel_NotInMonitorRef()
{
var monitor = MakeMon(1, "A");
monitor.CommunicationMethod = "DDC/CI";
var monitors = new List<Monitor> { monitor };
var (result, _) = MonitorDtoProjector.BuildCapabilitiesResult(monitors, EmptyHidden, number: 1, id: null);
Assert.AreEqual("DDC/CI", result!.CommunicationMethod);
Assert.IsNull(result.Monitor.Method, "Method should be null on the monitor ref for capabilities");
}
[TestMethod]
public void BuildCapabilitiesResult_WithVcpCaps_ProjectsCodesAndFormatsDiscrete()
{
var monitor = MakeMon(1, "A");
var caps = new VcpCapabilities();
// Add brightness (continuous) and color-temperature (discrete with known values)
caps.SupportedVcpCodes[0x10] = new VcpCodeInfo(0x10, "Brightness");
caps.SupportedVcpCodes[0x14] = new VcpCodeInfo(0x14, "Select Color Preset", new List<int> { 0x05 });
monitor.VcpCapabilitiesInfo = caps;
var monitors = new List<Monitor> { monitor };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(monitors, EmptyHidden, number: 1, id: null);
Assert.IsNull(error);
Assert.AreEqual(2, result!.VcpCodes.Count);
var brightness = result.VcpCodes[0]; // sorted: 0x10 comes before 0x14
Assert.AreEqual("0x10", brightness.Code);
Assert.IsTrue(brightness.Continuous);
Assert.IsNull(brightness.DiscreteValues);
var colorTemp = result.VcpCodes[1];
Assert.AreEqual("0x14", colorTemp.Code);
Assert.IsFalse(colorTemp.Continuous);
Assert.IsNotNull(colorTemp.DiscreteValues);
Assert.AreEqual(1, colorTemp.DiscreteValues!.Count);
// FormatDiscrete(0x14, 0x05) → "6500K (0x05)"
Assert.AreEqual("6500K (0x05)", colorTemp.DiscreteValues[0]);
}
[TestMethod]
public void BuildCapabilitiesResult_SettingFilter_ReturnsOnlyMatchingCode()
{
var monitor = MakeMon(1, "A");
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x14] = new VcpCodeInfo(0x14, "Select Color Preset", new List<int> { 0x05 });
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11 });
monitor.VcpCapabilitiesInfo = caps;
var monitors = new List<Monitor> { monitor };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(
monitors, EmptyHidden, number: 1, id: null, settingFilter: "input-source", customMappings: null);
Assert.IsNull(error);
Assert.AreEqual(1, result!.VcpCodes.Count);
Assert.AreEqual("0x60", result.VcpCodes[0].Code);
}
[TestMethod]
public void BuildCapabilitiesResult_SettingFilter_NonDiscrete_ReturnsArgumentError()
{
var monitor = MakeMon(1, "A");
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x10] = new VcpCodeInfo(0x10, "Brightness");
monitor.VcpCapabilitiesInfo = caps;
var monitors = new List<Monitor> { monitor };
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(
monitors, EmptyHidden, number: 1, id: null, settingFilter: "brightness", customMappings: null);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
}
[TestMethod]
public void BuildCapabilitiesResult_CustomMapping_UsesCustomName()
{
var monitor = MakeMon(1, "A");
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11 });
monitor.VcpCapabilitiesInfo = caps;
var monitors = new List<Monitor> { monitor };
var custom = new List<CustomVcpValueMapping>
{
new() { VcpCode = 0x60, Value = 0x11, CustomName = "Living Room TV", ApplyToAll = true },
};
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(
monitors, EmptyHidden, number: 1, id: null, settingFilter: null, customMappings: custom);
Assert.IsNull(error);
var inputCode = result!.VcpCodes[0];
Assert.AreEqual("0x60", inputCode.Code);
Assert.IsNotNull(inputCode.DiscreteValues);
Assert.AreEqual("Living Room TV (0x11)", inputCode.DiscreteValues![0]);
}
[TestMethod]
public void BuildGetResult_CustomMapping_UsesCustomName()
{
var monitor = MakeMon(1, "A");
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11 });
monitor.VcpCapabilitiesInfo = caps;
monitor.ReadValues |= MonitorReadFlags.InputSource;
monitor.CurrentInputSource = 0x11;
var monitors = new List<Monitor> { monitor };
var custom = new List<CustomVcpValueMapping>
{
new() { VcpCode = 0x60, Value = 0x11, CustomName = "Living Room TV", ApplyToAll = true },
};
var (result, error) = MonitorDtoProjector.BuildGetResult(
monitors, EmptyHidden, number: null, id: null, settingFilter: "input-source", customMappings: custom);
Assert.IsNull(error);
var setting = result!.Monitors[0].Settings[0];
Assert.AreEqual("input-source", setting.Setting);
Assert.AreEqual("Living Room TV (0x11)", setting.Display);
}
// ─── ResolveMonitor ───────────────────────────────────────────────────────
[TestMethod]
public void ResolveMonitor_NoSelector_ReturnsSelectorMissing()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (monitor, error) = MonitorDtoProjector.ResolveMonitor(monitors, null, null);
Assert.IsNull(monitor);
Assert.AreEqual(CliErrorCodes.SelectorMissing, error!.Code);
}
[TestMethod]
public void ResolveMonitor_ByNumber_FindsCorrectMonitor()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (monitor, error) = MonitorDtoProjector.ResolveMonitor(monitors, 2, null);
Assert.IsNull(error);
Assert.AreEqual("B", monitor!.Id);
}
[TestMethod]
public void ResolveMonitor_ById_FindsCorrectMonitor()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (monitor, error) = MonitorDtoProjector.ResolveMonitor(monitors, null, "B");
Assert.IsNull(error);
Assert.AreEqual("B", monitor!.Id);
}
[TestMethod]
public void ResolveMonitor_BothSelectors_IdWins()
{
var monitors = new List<Monitor> { MakeMon(1, "A"), MakeMon(2, "B") };
var (monitor, error) = MonitorDtoProjector.ResolveMonitor(monitors, 1, "B");
Assert.IsNull(error);
Assert.AreEqual("B", monitor!.Id);
}
[TestMethod]
public void ResolveMonitor_BothSelectors_IdNotFound_ReturnsError()
{
var monitors = new List<Monitor> { MakeMon(1, "A") };
var (monitor, error) = MonitorDtoProjector.ResolveMonitor(monitors, 1, "Z");
Assert.IsNull(monitor);
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error!.Code);
}
// ─── FormatDiscrete / OrientationDegrees ─────────────────────────────────
[TestMethod]
public void FormatDiscrete_KnownValue_ReturnsNameAndHex()
{
// 0x14:0x05 = "6500K"
var s = MonitorDtoProjector.FormatDiscrete(0x14, 0x05);
Assert.AreEqual("6500K (0x05)", s);
}
[TestMethod]
public void FormatDiscrete_UnknownValue_ReturnsHexOnly()
{
var s = MonitorDtoProjector.FormatDiscrete(0x14, 0xFF);
Assert.AreEqual("0xFF", s);
}
[TestMethod]
public void OrientationDegrees_Index0_Returns0Degrees()
=> Assert.AreEqual("0°", MonitorDtoProjector.OrientationDegrees(0));
[TestMethod]
public void OrientationDegrees_Index1_Returns90Degrees()
=> Assert.AreEqual("90°", MonitorDtoProjector.OrientationDegrees(1));
[TestMethod]
public void OrientationDegrees_Index2_Returns180Degrees()
=> Assert.AreEqual("180°", MonitorDtoProjector.OrientationDegrees(2));
[TestMethod]
public void OrientationDegrees_Index3_Returns270Degrees()
=> Assert.AreEqual("270°", MonitorDtoProjector.OrientationDegrees(3));
[TestMethod]
public void OrientationDegrees_UnknownIndex_ReturnsIndexLabel()
=> Assert.AreEqual("index 7", MonitorDtoProjector.OrientationDegrees(7));
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Shared fake <see cref="IMonitorManager"/> whose every write succeeds. Used by the executor and
/// request-handler tests that need a manager but exercise no hardware-failure path.
/// </summary>
internal sealed class NoOpManager : IMonitorManager
{
public Task<MonitorOperationResult> SetBrightnessAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetContrastAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetVolumeAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetColorTemperatureAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetInputSourceAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetPowerStateAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
public Task<MonitorOperationResult> SetRotationAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Success());
}

View File

@@ -0,0 +1,35 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerDisplay.Ipc.UnitTests</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Ipc.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.CodeDom">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerDisplay.Ipc\PowerDisplay.Ipc.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using PowerDisplay.Models;
namespace PowerDisplay.Ipc.UnitTests;
[TestClass]
public class ProfileDtoProjectorTests
{
// ─── BuildProfileListResult ──────────────────────────────────────────────
[TestMethod]
public void BuildProfileListResult_EmptyProfiles_ReturnsEmptyList()
{
var profiles = new PowerDisplayProfiles();
var result = ProfileDtoProjector.BuildProfileListResult(profiles);
Assert.IsNotNull(result);
Assert.AreEqual(0, result.Profiles.Count);
Assert.AreEqual("profiles", result.Command);
}
[TestMethod]
public void BuildProfileListResult_ProjectsNameMonitorCountAndLastModified()
{
var lastModified = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc);
var profile = new PowerDisplayProfile
{
Id = 1,
Name = "Night",
MonitorSettings = new List<ProfileMonitorSetting>
{
new ProfileMonitorSetting("MON-A", brightness: 30),
new ProfileMonitorSetting("MON-B", brightness: 40),
},
LastModified = lastModified,
};
var profiles = new PowerDisplayProfiles();
profiles.Profiles.Add(profile);
var result = ProfileDtoProjector.BuildProfileListResult(profiles);
Assert.AreEqual(1, result.Profiles.Count);
var info = result.Profiles[0];
Assert.AreEqual("Night", info.Name);
Assert.AreEqual(2, info.MonitorCount);
// ISO 8601 round-trip ("o") format, invariant culture — mirrors ProfilesCommand.Run
Assert.AreEqual(lastModified.ToString("o", System.Globalization.CultureInfo.InvariantCulture), info.LastModified);
}
[TestMethod]
public void BuildProfileListResult_NullProfiles_ThrowsArgumentNullException()
{
Assert.ThrowsException<ArgumentNullException>(
() => ProfileDtoProjector.BuildProfileListResult(null!));
}
[TestMethod]
public void BuildProfileListResult_IncludesId()
{
var profiles = new PowerDisplayProfiles();
var p = new PowerDisplayProfile("Gaming", new List<ProfileMonitorSetting>
{
new ProfileMonitorSetting("MON1", 50, null, null, null),
});
p.Id = 4;
profiles.Profiles.Add(p);
var result = ProfileDtoProjector.BuildProfileListResult(profiles);
Assert.AreEqual(4, result.Profiles[0].Id);
Assert.AreEqual("Gaming", result.Profiles[0].Name);
}
}

View File

@@ -0,0 +1,558 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Models;
using PowerDisplay.Contracts;
using PowerDisplay.Ipc;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc.UnitTests;
/// <summary>
/// Unit tests for <see cref="SetCommandExecutor"/>. Uses fake <see cref="IMonitorManager"/>
/// implementations to cover all structured error categories (exit codes 15) and the
/// success path (exit code 0 with before→after values).
/// </summary>
[TestClass]
public class SetCommandExecutorTests
{
// ─── Shared test fixtures ─────────────────────────────────────────────────
private static readonly IReadOnlySet<string> EmptyHidden =
new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Builds a <see cref="VcpCapabilities"/> that advertises the given VCP codes (no discrete values).
/// Used to make <see cref="Monitor.SupportsPowerState"/> / <see cref="Monitor.SupportsInputSource"/> return true.
/// </summary>
private static VcpCapabilities VcpCapsWithCodes(params byte[] codes)
{
var caps = new VcpCapabilities();
foreach (var code in codes)
{
caps.SupportedVcpCodes[code] = new VcpCodeInfo(code, $"0x{code:X2}");
}
return caps;
}
/// <summary>A monitor with brightness support, current value 42, GDI device name present.</summary>
private static Monitor BrightnessMon() => new()
{
Id = "A",
MonitorNumber = 1,
Name = "TestMon",
CommunicationMethod = "DDC/CI",
GdiDeviceName = @"\\.\DISPLAY1",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.Brightness,
CurrentBrightness = 42,
};
/// <summary>A monitor with contrast support, current value 55.</summary>
private static Monitor ContrastMon() => new()
{
Id = "B",
MonitorNumber = 2,
Name = "ContrastMon",
CommunicationMethod = "DDC/CI",
Capabilities = MonitorCapabilities.Contrast,
ReadValues = MonitorReadFlags.Contrast,
CurrentContrast = 55,
};
// ─── MonitorNotFound (exit code 1) ────────────────────────────────────────
[TestMethod]
public async Task Set_UnknownMonitorNumber_ReturnsMonitorNotFound()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 9, Setting = "brightness", RawValue = "50" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.MonitorNotFound, error!.Error.Code);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_HiddenMonitor_ReturnsMonitorNotFound()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var hidden = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase) { "A" };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "50" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, hidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliExitCodes.MonitorNotFound, error!.Error.ExitCode);
}
// ─── OutOfRange (exit code 2) ─────────────────────────────────────────────
[TestMethod]
public async Task Set_Brightness_OutOfRange_High_ReturnsOutOfRange()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "999" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.OutOfRange, error!.Error.Code);
Assert.AreEqual(CliExitCodes.OutOfRange, error.Error.ExitCode);
Assert.IsNotNull(error.Error.ExpectedRange);
StringAssert.Contains(error.Error.ExpectedRange, "100");
}
[TestMethod]
public async Task Set_Brightness_OutOfRange_Negative_ReturnsOutOfRange()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "-1" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.OutOfRange, error!.Error.ExitCode);
}
[TestMethod]
public async Task Set_Contrast_OutOfRange_ReturnsOutOfRange()
{
var snapshot = new List<Monitor> { ContrastMon() };
var req = new SetRequest { MonitorNumber = 2, Setting = "contrast", RawValue = "101" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.OutOfRange, error!.Error.Code);
Assert.AreEqual(CliExitCodes.OutOfRange, error.Error.ExitCode);
}
// ─── InvalidDiscreteValue (exit code 3) ───────────────────────────────────
[TestMethod]
public async Task Set_ColorTemperature_InvalidValue_ReturnsInvalidDiscreteValue()
{
var monitor = new Monitor
{
Id = "C",
MonitorNumber = 3,
Name = "ColorMon",
SupportsColorTemperature = true,
ReadValues = MonitorReadFlags.ColorTemperature,
CurrentColorTemperature = 0x05,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 3, Setting = "color-temperature", RawValue = "not-a-color" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_Orientation_InvalidDegrees_ReturnsInvalidDiscreteValue()
{
var monitor = new Monitor
{
Id = "D",
MonitorNumber = 4,
Name = "OrientMon",
GdiDeviceName = @"\\.\DISPLAY4",
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 4, Setting = "orientation", RawValue = "45" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_InputSource_ValueNotInSupportedList_ReturnsInvalidDiscreteValue()
{
// The monitor advertises input-source value 0x11. 0x99 parses as a valid byte but is NOT in
// that set, so it must be rejected via the supported-set branch (MakeDiscreteUnsupportedError)
// before any hardware write — a different path from the hex-parse failure. Use a VcpCodeInfo
// WITH a discrete value list (VcpCapsWithCodes builds an empty set, which accepts any value).
var caps = new VcpCapabilities();
caps.SupportedVcpCodes[0x60] = new VcpCodeInfo(0x60, "Input Source", new List<int> { 0x11 });
var monitor = new Monitor
{
Id = "E",
MonitorNumber = 5,
Name = "InputMon",
VcpCapabilitiesInfo = caps,
ReadValues = MonitorReadFlags.InputSource,
CurrentInputSource = 0x11,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 5, Setting = "input-source", RawValue = "0x99" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, error.Error.ExitCode);
// Pin the supported-set branch specifically (not the hex-parse branch, which shares the code):
// DiscreteNotInSet is the "value not in the monitor's advertised set" message id.
Assert.AreEqual(CliMessageIds.DiscreteNotInSet, error.Error.MessageId);
Assert.IsNotNull(error.Error.Supported);
}
// ─── Discrete settings are hex-only: friendly names are rejected ──────────
[TestMethod]
public async Task Set_ColorTemperature_ByFriendlyName_ReturnsInvalidDiscreteValue()
{
var monitor = new Monitor
{
Id = "C",
MonitorNumber = 3,
Name = "ColorMon",
SupportsColorTemperature = true,
ReadValues = MonitorReadFlags.ColorTemperature,
CurrentColorTemperature = 0x05,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 3, Setting = "color-temperature", RawValue = "6500K" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
}
[TestMethod]
public async Task Set_InputSource_ByFriendlyName_ReturnsInvalidDiscreteValue()
{
var monitor = new Monitor
{
Id = "E",
MonitorNumber = 5,
Name = "InputMon",
VcpCapabilitiesInfo = VcpCapsWithCodes(0x60),
ReadValues = MonitorReadFlags.InputSource,
CurrentInputSource = 0x11,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 5, Setting = "input-source", RawValue = "HDMI-1" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
}
[TestMethod]
public async Task Set_PowerState_ByFriendlyName_ReturnsInvalidDiscreteValue()
{
var monitor = new Monitor
{
Id = "I",
MonitorNumber = 9,
Name = "PowerMon",
VcpCapabilitiesInfo = VcpCapsWithCodes(0xD6),
ReadValues = MonitorReadFlags.PowerState,
CurrentPowerState = 0x01,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 9, Setting = "power-state", RawValue = "On" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.InvalidDiscreteValue, error!.Error.Code);
}
// ─── UnsupportedFeature (exit code 4) ────────────────────────────────────
[TestMethod]
public async Task Set_Brightness_NotSupported_ReturnsUnsupportedFeature()
{
// Monitor with NO brightness capability flag
var monitor = new Monitor
{
Id = "F",
MonitorNumber = 6,
Name = "NoBrightnessMon",
Capabilities = MonitorCapabilities.None,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 6, Setting = "brightness", RawValue = "50" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.UnsupportedFeature, error!.Error.Code);
Assert.AreEqual(CliExitCodes.UnsupportedFeature, error.Error.ExitCode);
Assert.AreEqual(CliMessageIds.Unsupported, error.Error.MessageId);
}
[TestMethod]
public async Task Set_Orientation_NoGdiDevice_ReturnsUnsupportedFeature()
{
// Monitor with empty GdiDeviceName — orientation cannot be rotated
var monitor = new Monitor
{
Id = "G",
MonitorNumber = 7,
Name = "NoGdiMon",
GdiDeviceName = string.Empty,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 7, Setting = "orientation", RawValue = "90" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.UnsupportedFeature, error!.Error.Code);
Assert.AreEqual(CliExitCodes.UnsupportedFeature, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_Contrast_NotSupported_ReturnsUnsupportedFeature()
{
var snapshot = new List<Monitor> { BrightnessMon() }; // Brightness only, no contrast
var req = new SetRequest { MonitorNumber = 1, Setting = "contrast", RawValue = "50" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.AreEqual(CliErrorCodes.UnsupportedFeature, error!.Error.Code);
Assert.AreEqual(CliExitCodes.UnsupportedFeature, error.Error.ExitCode);
}
// ─── HardwareFailure (exit code 5) ────────────────────────────────────────
[TestMethod]
public async Task Set_Brightness_HardwareFailure_ReturnsHardwareFailure()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "50" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new FailingManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.HardwareFailure, error!.Error.Code);
Assert.AreEqual(CliExitCodes.HardwareFailure, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_Contrast_HardwareFailure_MessageFromManager()
{
var snapshot = new List<Monitor> { ContrastMon() };
var req = new SetRequest { MonitorNumber = 2, Setting = "contrast", RawValue = "60" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new FailingManager("DDC write timed out"), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.AreEqual(CliExitCodes.HardwareFailure, error!.Error.ExitCode);
Assert.AreEqual(CliMessageIds.HardwareFailure, error.Error.MessageId);
Assert.AreEqual("DDC write timed out", error.Error.Detail);
}
// ─── Success paths (exit code 0) ──────────────────────────────────────────
[TestMethod]
public async Task Set_Brightness_Success_ReturnsBeforeAfterValues()
{
var monitor = BrightnessMon();
monitor.CurrentBrightness = 30;
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "70" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual("brightness", result!.Setting);
Assert.AreEqual("30%", result.BeforeDisplay);
Assert.AreEqual("70%", result.AfterDisplay);
Assert.AreEqual(1, result.Monitor.Number);
}
[TestMethod]
public async Task Set_Brightness_BoundaryMin_Success()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "0" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.AreEqual("0%", result!.AfterDisplay);
}
[TestMethod]
public async Task Set_Brightness_BoundaryMax_Success()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "100" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.AreEqual("100%", result!.AfterDisplay);
}
[TestMethod]
public async Task Set_Contrast_Success_BeforeAfterDisplay()
{
var snapshot = new List<Monitor> { ContrastMon() };
var req = new SetRequest { MonitorNumber = 2, Setting = "contrast", RawValue = "80" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual("contrast", result!.Setting);
Assert.AreEqual("55%", result.BeforeDisplay);
Assert.AreEqual("80%", result.AfterDisplay);
}
[TestMethod]
public async Task Set_Brightness_BeforeUnknown_OmitsBeforeDisplay()
{
var monitor = new Monitor
{
Id = "A",
MonitorNumber = 1,
Name = "TestMon",
Capabilities = MonitorCapabilities.Brightness,
ReadValues = MonitorReadFlags.None, // supported but not read → before is unknown
CurrentBrightness = 0, // default, should not be reported
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "60" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.IsNull(result!.BeforeDisplay);
}
[TestMethod]
public async Task Set_Orientation_Success_BeforeAfterInDegrees()
{
var monitor = new Monitor
{
Id = "H",
MonitorNumber = 8,
Name = "OrientMon",
GdiDeviceName = @"\\.\DISPLAY8",
Orientation = 0, // currently 0°
ReadValues = MonitorReadFlags.Orientation,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 8, Setting = "orientation", RawValue = "90" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual("orientation", result!.Setting);
Assert.AreEqual("0°", result.BeforeDisplay);
Assert.AreEqual("90°", result.AfterDisplay);
}
[TestMethod]
public async Task Set_ByMonitorId_Success()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorId = "A", Setting = "brightness", RawValue = "55" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(error);
Assert.IsNotNull(result);
Assert.AreEqual("A", result!.Monitor.Id);
}
// ─── PowerState confirmation gate ────────────────────────────────────────
[TestMethod]
public async Task Set_PowerState_BlankingWithoutConfirm_ReturnsArgumentError()
{
var monitor = new Monitor
{
Id = "I",
MonitorNumber = 9,
Name = "PowerMon",
VcpCapabilitiesInfo = VcpCapsWithCodes(0xD6), // makes SupportsPowerState == true
ReadValues = MonitorReadFlags.PowerState,
CurrentPowerState = 0x01, // On
};
var snapshot = new List<Monitor> { monitor };
// 0x04 = Off (DPM) — a display-blanking state
var req = new SetRequest { MonitorNumber = 9, Setting = "power-state", RawValue = "0x04", ConfirmPowerOff = false };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
}
[TestMethod]
public async Task Set_PowerState_BlankingWithConfirm_Proceeds()
{
var monitor = new Monitor
{
Id = "I",
MonitorNumber = 9,
Name = "PowerMon",
VcpCapabilitiesInfo = VcpCapsWithCodes(0xD6), // makes SupportsPowerState == true
ReadValues = MonitorReadFlags.PowerState,
CurrentPowerState = 0x01,
};
var snapshot = new List<Monitor> { monitor };
var req = new SetRequest { MonitorNumber = 9, Setting = "power-state", RawValue = "0x04", ConfirmPowerOff = true };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
// No error — the confirmation flag was provided
Assert.IsNull(error);
Assert.IsNotNull(result);
// Pin the discrete success projection (the only discrete-set success in the suite): before/after
// are formatted via FormatDiscrete(0xD6, …), not the raw int. Self-pin to the product formatter
// (BeforeDisplay = "On (0x01)", AfterDisplay = "Off (DPM) (0x04)"). Catches a before/after swap
// or a dropped FormatDiscrete in ApplyDiscreteAsync.
Assert.AreEqual("power-state", result!.Setting);
Assert.AreEqual(MonitorDtoProjector.FormatDiscrete(0xD6, 0x01), result.BeforeDisplay);
Assert.AreEqual(MonitorDtoProjector.FormatDiscrete(0xD6, 0x04), result.AfterDisplay);
}
// ─── Unknown setting name ─────────────────────────────────────────────────
[TestMethod]
public async Task Set_UnknownSetting_ReturnsArgumentError()
{
var snapshot = new List<Monitor> { BrightnessMon() };
var req = new SetRequest { MonitorNumber = 1, Setting = "flicker-rate", RawValue = "60" };
var (result, error) = await SetCommandExecutor.ExecuteAsync(new NoOpManager(), snapshot, EmptyHidden, req, default);
Assert.IsNull(result);
Assert.IsNotNull(error);
Assert.AreEqual(CliErrorCodes.ArgumentError, error!.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, error.Error.ExitCode);
}
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// App-side executor for the relative <c>up</c>/<c>down</c> IPC commands. Resolves the target
/// monitor, looks up the continuous-setting descriptor, computes the clamped new value from the
/// monitor's current value and the step (explicit or the settings default), performs the DDC/CI or
/// WMI write, and returns the shared <see cref="CliSetResult"/> with before/after values.
/// <para>
/// Only continuous settings (brightness, contrast, volume) are adjustable: an unknown setting name
/// is an <c>ARGUMENT_ERROR</c>; a known-but-discrete setting (e.g. color-temperature) is
/// <c>UNSUPPORTED_FEATURE</c>. The CLI never sends those — this is app-side defense in depth.
/// </para>
/// </summary>
public static class AdjustCommandExecutor
{
public static async Task<(CliSetResult? Result, CliErrorResult? Error)> ExecuteAsync(
IMonitorManager manager,
IReadOnlyList<Monitor> snapshot,
IReadOnlySet<string> hidden,
AdjustRequest req,
bool isUp,
int defaultStep,
CancellationToken ct)
{
var commandName = isUp ? CliCommandNames.Up : CliCommandNames.Down;
var visible = MonitorDtoProjector.ExcludeHidden(snapshot, hidden);
var (monitor, resolveError) = MonitorDtoProjector.ResolveMonitor(visible, req.MonitorNumber, req.MonitorId);
if (resolveError is not null)
{
return (null, new CliErrorResult { Command = commandName, Error = resolveError });
}
var monitorRef = MonitorDtoProjector.ToRef(monitor!);
var setting = req.Setting?.Trim().ToLowerInvariant() ?? string.Empty;
var descriptor = CliSettingCatalog.TryGet(setting);
if (descriptor is null)
{
return (null, new CliErrorResult
{
Command = commandName,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.UnknownSettingAdjust,
Value = req.Setting,
},
});
}
if (descriptor.Kind != CliSettingKind.Continuous)
{
return (null, new CliErrorResult
{
Command = commandName,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.UnsupportedFeature,
MessageId = CliMessageIds.NotAdjustable,
Setting = setting,
},
});
}
if (!descriptor.Supports(monitor!))
{
return (null, CliErrorFactory.Unsupported(commandName, monitorRef, setting, descriptor.UnsupportedReason));
}
var step = req.Step ?? defaultStep;
if (step < 0)
{
return (null, new CliErrorResult
{
Command = commandName,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.OutOfRange,
Setting = "step",
Value = step.ToString(CultureInfo.InvariantCulture),
ExpectedRange = "[0, 2147483647]",
},
});
}
var beforeKnown = monitor!.ReadValues.HasFlag(descriptor.ReadFlag);
// Relative adjust is meaningless without a trustworthy starting value. If discovery never
// read this setting (the capability is advertised but the live VCP read failed),
// descriptor.Current returns a fabricated default (0 for brightness, 50 for contrast/volume).
// Adjusting from that would silently turn "up 10" into an absolute write to ~10 on a panel
// that may have been at any level. Surface it as a hardware failure rather than guessing.
if (!beforeKnown)
{
return (null, new CliErrorResult
{
Command = commandName,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.HardwareFailure,
MessageId = CliMessageIds.AdjustValueUnknown,
Setting = setting,
},
});
}
var current = descriptor.Current(monitor!);
var delta = isUp ? step : -step;
// Compute in long so a pathologically large --step cannot overflow int: `current + delta`
// could wrap negative and Math.Clamp of a wrapped value would invert the direction (an
// `up` ending at 0). Widen, clamp to [0, 100], then narrow back.
var newValue = (int)Math.Clamp((long)current + delta, 0, 100);
var op = await descriptor.Apply(manager, monitor.Id, newValue, ct);
// The server receives its own app-lifetime token; a client Ctrl+C/deadline only closes the
// pipe and is not propagated here. If this long write is interrupted by server shutdown
// before or after the non-interruptible hardware call, surface the cancellation as TIMEOUT
// when a response can still be returned.
ct.ThrowIfCancellationRequested();
if (!op.IsSuccess)
{
return (null, CliErrorFactory.HardwareFailure(commandName, monitorRef, op.ErrorMessage));
}
return (new CliSetResult
{
Command = commandName,
Monitor = monitorRef,
Setting = descriptor.Name,
// beforeKnown is guaranteed true here (the !beforeKnown case returned above).
BeforeDisplay = current + "%",
AfterDisplay = newValue + "%",
}, null);
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// Immutable per-request snapshot of the ViewModel/MonitorManager state a single CLI command
/// operates on, plus the lazy profile delegates. Built once on the UI thread and handed to the
/// matching <see cref="ICliCommandHandler"/>, so handlers never touch the live ViewModel directly.
/// </summary>
internal sealed class CliCommandContext
{
/// <summary>Initializes the context with the pre-fetched request state and profile delegates.</summary>
/// <param name="envelope">The parsed request envelope (command + typed payload).</param>
/// <param name="snapshot">Pre-fetched monitor list from <c>MainViewModel.SnapshotMonitors()</c>.</param>
/// <param name="hiddenIds">Pre-fetched hidden-ID set from <c>MainViewModel.GetHiddenMonitorIds()</c>.</param>
/// <param name="customMappings">User-defined VCP value name mappings.</param>
/// <param name="manager">The live <see cref="IMonitorManager"/> for hardware writes.</param>
/// <param name="defaultStep">The default relative-adjust step (mouse-wheel increment).</param>
/// <param name="loadProfilesAsync">Lazy profile loader, invoked only by profile commands.</param>
/// <param name="applyProfileAsync">
/// Applies a profile by id and returns its resolved name; <see langword="null"/> result means
/// "not found".
/// </param>
public CliCommandContext(
CliRequestEnvelope envelope,
IReadOnlyList<Monitor> snapshot,
IReadOnlySet<string> hiddenIds,
IReadOnlyList<CustomVcpValueMapping> customMappings,
IMonitorManager manager,
int defaultStep,
Func<CancellationToken, Task<PowerDisplayProfiles>> loadProfilesAsync,
Func<int, CancellationToken, Task<string?>> applyProfileAsync)
{
Envelope = envelope;
Snapshot = snapshot;
HiddenIds = hiddenIds;
CustomMappings = customMappings;
Manager = manager;
DefaultStep = defaultStep;
LoadProfilesAsync = loadProfilesAsync;
ApplyProfileAsync = applyProfileAsync;
}
/// <summary>The parsed request envelope (command + typed payload).</summary>
public CliRequestEnvelope Envelope { get; }
/// <summary>Pre-fetched monitor list snapshot.</summary>
public IReadOnlyList<Monitor> Snapshot { get; }
/// <summary>Set of monitor IDs hidden by user preference.</summary>
public IReadOnlySet<string> HiddenIds { get; }
/// <summary>User-defined VCP value name mappings.</summary>
public IReadOnlyList<CustomVcpValueMapping> CustomMappings { get; }
/// <summary>The live monitor manager for hardware writes.</summary>
public IMonitorManager Manager { get; }
/// <summary>The default relative-adjust step (mouse-wheel increment).</summary>
public int DefaultStep { get; }
/// <summary>Lazy asynchronous profile loader, invoked only by profile commands.</summary>
public Func<CancellationToken, Task<PowerDisplayProfiles>> LoadProfilesAsync { get; }
/// <summary>
/// Applies a profile by id (best-effort) and returns the resolved profile's name;
/// <see langword="null"/> when the profile is not found. The apply-profile handler uses the
/// returned name directly and must not call <see cref="LoadProfilesAsync"/> to recover it.
/// </summary>
public Func<int, CancellationToken, Task<string?>> ApplyProfileAsync { get; }
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Contracts;
namespace PowerDisplay.Ipc;
/// <summary>
/// Command-name → handler registry for the CLI IPC dispatcher. Adding a command is a single row here
/// plus its <see cref="ICliCommandHandler"/> implementation — no change to the dispatcher's control
/// flow. Lookup is ordinal to match the canonical <see cref="CliCommandNames"/> constants exactly.
/// </summary>
internal static class CliCommandHandlers
{
private static readonly IReadOnlyDictionary<string, ICliCommandHandler> Handlers =
new Dictionary<string, ICliCommandHandler>(StringComparer.Ordinal)
{
[CliCommandNames.List] = new ListCommandHandler(),
[CliCommandNames.Get] = new GetCommandHandler(),
[CliCommandNames.Set] = new SetCommandHandler(),
[CliCommandNames.Up] = new AdjustCommandHandler(),
[CliCommandNames.Down] = new AdjustCommandHandler(),
[CliCommandNames.Capabilities] = new CapabilitiesCommandHandler(),
[CliCommandNames.Profiles] = new ProfilesCommandHandler(),
[CliCommandNames.ApplyProfile] = new ApplyProfileCommandHandler(),
};
/// <summary>
/// Resolves the handler for <paramref name="command"/>. Returns <see langword="false"/> for an
/// unrecognized command name (a newer CLI talking to an older app), which the dispatcher maps to
/// <c>ARGUMENT_ERROR</c>.
/// </summary>
public static bool TryGet(string command, out ICliCommandHandler handler)
=> Handlers.TryGetValue(command, out handler!);
// ─── list ─────────────────────────────────────────────────────────────────
private sealed class ListCommandHandler : ICliCommandHandler
{
public Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
var result = MonitorDtoProjector.BuildListResult(context.Snapshot, context.HiddenIds);
return Task.FromResult(CliResponse.Serialize(result, ContractsJsonContext.Default.CliListResult));
}
}
// ─── get ──────────────────────────────────────────────────────────────────
private sealed class GetCommandHandler : ICliCommandHandler
{
public Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
var req = context.Envelope.Get ?? new GetRequest();
var outcome = MonitorDtoProjector.BuildGetResult(
context.Snapshot,
context.HiddenIds,
req.MonitorNumber,
req.MonitorId,
req.SettingFilter,
context.CustomMappings);
return Task.FromResult(CliResponse.ResultOrError(outcome, ContractsJsonContext.Default.CliGetResult));
}
}
// ─── set ──────────────────────────────────────────────────────────────────
private sealed class SetCommandHandler : ICliCommandHandler
{
public async Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
if (context.Envelope.Set is null)
{
return CliResponse.SerializeError(
CliResponse.MakeError(CliCommandNames.Set, CliErrorCodes.ArgumentError, "missing 'set' payload"));
}
var outcome = await SetCommandExecutor.ExecuteAsync(
context.Manager,
context.Snapshot,
context.HiddenIds,
context.Envelope.Set,
ct,
context.CustomMappings).ConfigureAwait(false);
return CliResponse.ResultOrError(outcome, ContractsJsonContext.Default.CliSetResult);
}
}
// ─── up / down (relative adjust) ────────────────────────────────────────────
private sealed class AdjustCommandHandler : ICliCommandHandler
{
public async Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
if (context.Envelope.Adjust is null)
{
return CliResponse.SerializeError(
CliResponse.MakeError(context.Envelope.Command, CliErrorCodes.ArgumentError, "missing 'adjust' payload"));
}
var outcome = await AdjustCommandExecutor.ExecuteAsync(
context.Manager,
context.Snapshot,
context.HiddenIds,
context.Envelope.Adjust,
isUp: context.Envelope.Command == CliCommandNames.Up,
context.DefaultStep,
ct).ConfigureAwait(false);
return CliResponse.ResultOrError(outcome, ContractsJsonContext.Default.CliSetResult);
}
}
// ─── capabilities ───────────────────────────────────────────────────────────
private sealed class CapabilitiesCommandHandler : ICliCommandHandler
{
public Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
var req = context.Envelope.Capabilities ?? new CapabilitiesRequest();
var outcome = MonitorDtoProjector.BuildCapabilitiesResult(
context.Snapshot,
context.HiddenIds,
req.MonitorNumber,
req.MonitorId,
req.SettingFilter,
context.CustomMappings);
return Task.FromResult(CliResponse.ResultOrError(outcome, ContractsJsonContext.Default.CliCapabilitiesResult));
}
}
// ─── profiles ───────────────────────────────────────────────────────────────
private sealed class ProfilesCommandHandler : ICliCommandHandler
{
public async Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
var profiles = await context.LoadProfilesAsync(ct).ConfigureAwait(false);
var result = ProfileDtoProjector.BuildProfileListResult(profiles);
return CliResponse.Serialize(result, ContractsJsonContext.Default.CliProfileListResult);
}
}
// ─── apply-profile ────────────────────────────────────────────────────────
private sealed class ApplyProfileCommandHandler : ICliCommandHandler
{
public async Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
{
var profileId = context.Envelope.ApplyProfile?.ProfileId ?? 0;
if (profileId <= 0)
{
return CliResponse.SerializeError(
CliResponse.MakeError(CliCommandNames.ApplyProfile, CliErrorCodes.ArgumentError, "profile id must be positive"));
}
var name = await context.ApplyProfileAsync(profileId, ct).ConfigureAwait(false);
if (name is null)
{
return CliResponse.SerializeError(
CliResponse.MakeCodedError(
CliCommandNames.ApplyProfile,
CliErrorCodes.ArgumentError,
CliMessageIds.ProfileNotFound,
value: profileId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
var applyResult = new CliApplyProfileResult { ProfileId = profileId, Profile = name };
return CliResponse.Serialize(applyResult, ContractsJsonContext.Default.CliApplyProfileResult);
}
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using PowerDisplay.Contracts;
namespace PowerDisplay.Ipc;
/// <summary>
/// Shared factories for the <see cref="CliErrorResult"/> envelopes that the <c>set</c> and relative
/// <c>up</c>/<c>down</c> executors emit identically — only the owning command name differs. The app
/// stamps a <see cref="CliError.Code"/> + <see cref="CliError.MessageId"/> + structured fields; the
/// CLI localizes the human-readable text (see <c>CliErrorLocalizer</c>).
/// </summary>
internal static class CliErrorFactory
{
/// <summary>UNSUPPORTED_FEATURE: the monitor does not support the named setting.</summary>
public static CliErrorResult Unsupported(string command, CliMonitorRef monitorRef, string settingName, string unsupportedReason)
=> new()
{
Command = command,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.UnsupportedFeature,
MessageId = CliMessageIds.Unsupported,
Setting = settingName,
Detail = unsupportedReason,
},
};
/// <summary>
/// HARDWARE_FAILURE: the DDC/CI or GDI write failed. <paramref name="errorMessage"/> (when present)
/// is carried verbatim as the technical diagnostic; the CLI supplies the localized message.
/// </summary>
public static CliErrorResult HardwareFailure(string command, CliMonitorRef monitorRef, string? errorMessage)
=> new()
{
Command = command,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.HardwareFailure,
MessageId = CliMessageIds.HardwareFailure,
Detail = errorMessage,
},
};
}

View File

@@ -0,0 +1,438 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Contracts;
namespace PowerDisplay.Ipc;
/// <summary>
/// App-side named-pipe server that accepts CLI connections and dispatches each request through
/// <see cref="ICliRequestProcessor"/>.
/// <para>
/// <b>Protocol:</b> One connection = one request/response exchange. The server reads one
/// <c>'\n'</c>-delimited JSON line, calls <see cref="ICliRequestProcessor.HandleAsync"/>, writes one
/// JSON line back, then closes the connection. Unicode encoding mirrors
/// <c>PowerDisplay/Helpers/NamedPipeProcessor.cs</c>.
/// </para>
/// <para>
/// <b>ACL:</b> Uses <see cref="NamedPipeServerStreamAcl.Create"/> with a
/// <see cref="PipeSecurity"/> that grants the <em>current user's</em> SID
/// <c>ReadWrite | CreateNewInstance</c>, so a same-user non-elevated CLI can connect to a
/// same-user elevated app (elevation changes the integrity level, not the user SID). The ACE is
/// deliberately scoped to the owner rather than
/// <see cref="WellKnownSidType.AuthenticatedUserSid"/>: named pipes are not session-isolated (the
/// session id in <see cref="PipeNames.CliServer"/> only avoids name collisions), so an
/// AuthenticatedUsers ACE would let any other logged-on user drive this user's monitors. Pattern
/// sourced from <c>MouseWithoutBorders/App/Class/IClipboardHelper.cs IpcChannel&lt;T&gt;.StartIpcServer</c>.
/// </para>
/// <para>
/// <b>Concurrency:</b> the accept loop serves one request at a time — it waits for a connection,
/// runs it to completion, then accepts the next. This is sufficient for the one-shot CLI client.
/// To keep the well-known pipe name owned continuously (see <see cref="CreateServerStream"/>), the
/// loop always creates the replacement instance before disposing the just-served one: it tries
/// right after the connection is accepted (fast path, before serving, so up to two instances exist
/// briefly), and if that attempt fails it serves the current request anyway and retries replacement
/// creation (bounded delay between attempts) until one succeeds or the app is shutting down.
/// <see cref="NamedPipeServerStream.MaxAllowedServerInstances"/> is passed to allow the brief
/// overlap, not to serve requests concurrently.
/// </para>
/// <para>
/// <b>Limitation:</b> because the loop is single-instance, one in-flight request holds the sole
/// pipe instance until <see cref="ICliRequestProcessor.HandleAsync"/> returns. A blocking DDC/CI
/// hardware write cannot be cancelled mid-call (the underlying Win32 <c>SetVCPFeature</c> I2C
/// transaction is synchronous), so a slow or hung monitor serializes every subsequent CLI request
/// behind it until the OS DDC/CI layer times out. This is an accepted trade-off for the one-shot
/// CLI; making the server handle connections concurrently would require guarding the shared
/// ViewModel / MonitorManager state the handler currently touches single-threaded.
/// </para>
/// </summary>
public sealed class CliPipeServer
{
private readonly ICliRequestProcessor _processor;
private readonly ICliLogger _logger;
/// <summary>
/// Initialises the server with the request handler that will be called for each connection.
/// </summary>
/// <param name="processor">The processor that handles each request. Must not be null.</param>
/// <param name="logger">Optional host logger. When omitted, logging is disabled.</param>
public CliPipeServer(ICliRequestProcessor processor, ICliLogger? logger = null)
{
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_logger = logger ?? NullCliLogger.Instance;
}
/// <summary>
/// Starts the background accept loop. Fire-and-forget: returns immediately; the loop runs
/// until <paramref name="cancellationToken"/> is cancelled.
/// </summary>
/// <param name="cancellationToken">Token that stops the server when cancelled.</param>
public void Start(CancellationToken cancellationToken)
{
_ = Task.Run(() => AcceptLoopAsync(cancellationToken), cancellationToken);
}
// ─── Private implementation ───────────────────────────────────────────────
private async Task AcceptLoopAsync(CancellationToken ct)
{
var pipeName = PipeNames.CliServer();
// Scope pipe access to the current user's SID (not AuthenticatedUsers). Elevation changes the
// integrity level, not the user SID, so a same-user non-elevated CLI can still connect to a
// same-user elevated app, while other logged-on users are denied (named pipes are not
// session-isolated). Fall back to AuthenticatedUsers only if the owner SID is somehow null.
using var currentIdentity = WindowsIdentity.GetCurrent();
var ownerSid = currentIdentity.User
?? new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
ownerSid,
PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance,
AccessControlType.Allow));
_logger.LogInfo($"[PowerDisplay CLI IPC] Server starting on pipe '{pipeName}'");
// Keep one instance of the well-known pipe name alive at all times so it is never left
// unowned between requests. Only the FIRST instance uses PipeOptions.FirstPipeInstance, which
// fails loudly if another (possibly malicious) process already owns the predictable,
// session-scoped name at startup. Every later instance is guaranteed to be created BEFORE the
// just-served one is disposed, so at least one instance always holds the name — closing the
// gap a per-request create/dispose would otherwise reopen after every request. Without this, a
// same- or cross-user process could win that gap with CreateNamedPipe and take the name under
// its own ACL, denying the real server (its FirstPipeInstance create would fail forever)
// afterwards. The CLI client independently verifies the server's process identity
// (<c>PipeServerIdentity.IsTrustedServer</c>) before trusting a response, so this loop's job is
// ownership continuity, not authenticating the connection.
NamedPipeServerStream? listener = null;
try
{
while (!ct.IsCancellationRequested)
{
try
{
// First create (startup, or post-error recovery when no instance of ours is alive)
// asserts first-instance ownership; the overlap create below deliberately does not.
listener ??= CreateServerStream(pipeName, security, firstInstance: true);
await listener.WaitForConnectionAsync(ct).ConfigureAwait(false);
// A client is connected to `listener`. Fast path: try to stand up the replacement
// instance right now, while the connected one still holds the name, so the common
// case never drops ownership. If that first attempt throws, do NOT drop this
// request and do NOT dispose `connected` yet — serve it first, then keep retrying
// replacement creation (bounded delay before every attempt) until it succeeds or the
// app is shutting down. `connected` is only disposed once a replacement exists, or
// when shutdown makes continued ownership irrelevant.
var connected = listener;
listener = null;
// Everything from here on runs under one try/finally so `connected` is disposed no
// matter which step throws (fast-path create, serving, or the post-serving retry) —
// including an app-shutdown OperationCanceledException, which must still unwind
// through this finally before reaching the accept loop's own break/dispose logic.
try
{
// A successful fast-path replacement is assigned straight into `listener`
// (rather than a separate local) so that if serving below is cancelled by app
// shutdown, the exception unwinds past this point with `listener` already
// owning the new instance. The accept loop's outer `finally { listener?.Dispose();
// }` then disposes it — otherwise a successfully created replacement would be
// stranded in a local variable that nothing ever disposes.
try
{
listener = CreateServerStream(pipeName, security, firstInstance: false);
}
catch (Exception ex) when (IsRecoverableCreationException(ex))
{
_logger.LogWarning($"[PowerDisplay CLI IPC] Failed to create replacement pipe instance before serving; will retry after serving the current request: {ex.GetType().Name}: {ex.Message}");
}
try
{
await ServeOneAsync(connected, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A per-request failure (e.g. an unexpected exception while serving this
// client) must not be conflated with a listener/replacement failure: it is
// logged and swallowed here so a replacement already created above (or
// retried below) is still kept for the next iteration.
_logger.LogError($"[PowerDisplay CLI IPC] Error while serving a request: {ex.GetType().Name}: {ex.Message}");
}
if (listener is null)
{
// The fast-path attempt above failed (or threw a non-recoverable exception
// that already propagated out of this method), so this is itself a retry —
// the bounded delay is awaited before every attempt inside the helper,
// including this first post-serving one.
listener = await CreateReplacementWithRetryAsync(
() => CreateServerStream(pipeName, security, firstInstance: false),
RetryDelayAsync,
ct,
_logger).ConfigureAwait(false);
}
}
finally
{
connected.Dispose();
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex) when (IsRecoverableCreationException(ex))
{
_logger.LogError($"[PowerDisplay CLI IPC] server loop error: {ex.GetType().Name}: {ex.Message}");
// Drop the (possibly broken) current instance and back off before retrying, so a
// persistent create failure — e.g. another process holding the name, which
// FirstPipeInstance surfaces as an exception — does not spin the loop. The next
// iteration recreates with FirstPipeInstance (no instance of ours is alive here).
listener?.Dispose();
listener = null;
try
{
await Task.Delay(RetryDelayMilliseconds, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
finally
{
listener?.Dispose();
}
_logger.LogInfo("[PowerDisplay CLI IPC] Server stopped.");
}
/// <summary>Bounded delay between replacement-creation retry attempts (also used by the
/// listener-recovery back-off above), so a persistent failure does not spin the loop.</summary>
private const int RetryDelayMilliseconds = 500;
private static Task RetryDelayAsync(CancellationToken ct) => Task.Delay(RetryDelayMilliseconds, ct);
/// <summary>
/// Whether <paramref name="ex"/> represents a recoverable pipe-creation failure (e.g. another
/// process transiently holding the name, or an ACL/permissions hiccup) that is worth retrying.
/// Anything else is treated as a non-recoverable / programming error and must propagate instead
/// of being silently retried forever. Shared by every <see cref="CreateServerStream"/> call site
/// that retries on failure, so all of them apply the same recoverable/non-recoverable boundary.
/// </summary>
private static bool IsRecoverableCreationException(Exception ex) => ex is IOException or UnauthorizedAccessException;
/// <summary>
/// Repeatedly invokes <paramref name="createReplacement"/>, waiting <paramref name="delayAsync"/>
/// before every attempt — including the first — until it returns successfully. This is called
/// only after the fast-path replacement create (attempted right after a connection is accepted,
/// before serving it) has already failed once and the current request has since been served, so
/// the very first attempt made here is itself a retry and must be preceded by the same bounded
/// backoff as every subsequent one; it is awaited (not run in the background) as part of the
/// caller's control flow before moving on to the next accept iteration. Only
/// <see cref="IsRecoverableCreationException"/> failures are retried — any other exception (a
/// programming/non-recoverable error) propagates immediately without retrying or delaying
/// further. Propagates <see cref="OperationCanceledException"/> from <paramref name="delayAsync"/>
/// without retrying further, so app shutdown stops the retry instead of spinning. Internal so the
/// retry mechanic can be unit-tested with a fake factory and a no-op delay.
/// </summary>
/// <param name="createReplacement">Creates one replacement pipe instance; may throw.</param>
/// <param name="delayAsync">Awaited before every attempt; production passes a bounded
/// <see cref="Task.Delay(int, CancellationToken)"/>, tests can pass a no-op.</param>
/// <param name="ct">Cancellation token; observed by <paramref name="delayAsync"/>.</param>
/// <param name="logger">Optional host logger. When omitted, logging is disabled.</param>
internal static async Task<NamedPipeServerStream> CreateReplacementWithRetryAsync(
Func<NamedPipeServerStream> createReplacement,
Func<CancellationToken, Task> delayAsync,
CancellationToken ct,
ICliLogger? logger = null)
{
logger ??= NullCliLogger.Instance;
while (true)
{
await delayAsync(ct).ConfigureAwait(false);
try
{
return createReplacement();
}
catch (Exception ex) when (IsRecoverableCreationException(ex))
{
logger.LogError($"[PowerDisplay CLI IPC] Failed to create replacement pipe instance; retrying: {ex.GetType().Name}: {ex.Message}");
}
}
}
/// <summary>
/// Creates one server-stream instance for the CLI pipe. Only the first instance for the process
/// lifetime passes <see cref="PipeOptions.FirstPipeInstance"/> (to fail loudly if the predictable,
/// session-scoped name is already owned by another process at startup); every subsequent instance
/// omits it, because by design an instance of ours is always already alive when the next is
/// created, so <see cref="PipeOptions.FirstPipeInstance"/> would spuriously fail. Internal so the
/// ownership mechanic can be unit-tested.
/// </summary>
internal static NamedPipeServerStream CreateServerStream(string pipeName, PipeSecurity security, bool firstInstance)
{
var options = PipeOptions.Asynchronous;
if (firstInstance)
{
options |= PipeOptions.FirstPipeInstance;
}
return NamedPipeServerStreamAcl.Create(
pipeName,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
options,
inBufferSize: 0,
outBufferSize: 0,
security);
}
private async Task ServeOneAsync(NamedPipeServerStream server, CancellationToken ct)
{
// leaveOpen: true — the pipe stream is owned by the caller; disposing reader/writer
// must not close it prematurely.
using var reader = new StreamReader(server, CliPipeProtocol.PipeEncoding, detectEncodingFromByteOrderMarks: false, bufferSize: CliPipeProtocol.BufferSize, leaveOpen: true);
using var writer = new StreamWriter(server, CliPipeProtocol.PipeEncoding, bufferSize: CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
// Bound the read by both time and length so a client that connects but never sends a
// (complete) line cannot stall the single-threaded accept loop or balloon memory.
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
readCts.CancelAfter(CliPipeProtocol.ReadTimeoutMilliseconds);
string? requestJson;
try
{
requestJson = await ReadBoundedLineAsync(reader, CliPipeProtocol.MaxRequestChars, readCts.Token).ConfigureAwait(false);
}
catch (InvalidDataException)
{
_logger.LogWarning($"[PowerDisplay CLI IPC] Request exceeded {CliPipeProtocol.MaxRequestChars} chars; closing connection.");
return;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Read timeout (not app shutdown — that propagates to break the accept loop).
_logger.LogWarning("[PowerDisplay CLI IPC] Request read timed out; closing connection.");
return;
}
if (string.IsNullOrEmpty(requestJson))
{
_logger.LogWarning("[PowerDisplay CLI IPC] Received empty/null request line; closing connection.");
return;
}
var responseJson = await _processor.HandleAsync(requestJson, ct).ConfigureAwait(false);
// Bound the write + drain the same way the read is bounded above. The pipe uses a 0-byte
// output buffer, so both WriteLineAsync and WaitForPipeDrain block until the client reads;
// a connected client that never reads must not wedge the single-threaded accept loop.
using var writeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
writeCts.CancelAfter(CliPipeProtocol.WriteTimeoutMilliseconds);
// Drain rationale: without WaitForPipeDrain, disposing the handle immediately after an
// AutoFlush write can truncate a large response the client has not finished reading,
// surfacing as a spurious deserialize-mismatch on the CLI side. WaitForPipeDrain has no
// timeout/CancellationToken overload, so run it on a worker and bound it via writeCts;
// disposing the pipe (the caller's `using`) unblocks a still-waiting worker.
try
{
await writer.WriteLineAsync(responseJson.AsMemory(), writeCts.Token).ConfigureAwait(false);
var drainTask = Task.Run(
() =>
{
try
{
server.WaitForPipeDrain();
}
catch (IOException)
{
}
catch (ObjectDisposedException)
{
}
},
CancellationToken.None);
await drainTask.WaitAsync(writeCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Write/drain timeout (not app shutdown — that propagates to break the accept loop).
_logger.LogWarning("[PowerDisplay CLI IPC] Response write/drain timed out; closing connection.");
}
catch (IOException)
{
}
catch (ObjectDisposedException)
{
}
}
/// <summary>
/// Reads one <c>'\n'</c>-delimited line, swallowing a trailing <c>'\r'</c>, but never buffering
/// more than <paramref name="maxChars"/> characters. Returns the line (without its terminator),
/// or <see langword="null"/> at end-of-stream with no data. Throws <see cref="InvalidDataException"/>
/// when the line would exceed <paramref name="maxChars"/>.
/// </summary>
internal static async Task<string?> ReadBoundedLineAsync(TextReader reader, int maxChars, CancellationToken ct)
{
var builder = new StringBuilder();
var buffer = new char[CliPipeProtocol.BufferSize];
while (true)
{
// Honour the read deadline / app shutdown even if the underlying reader does not observe
// the token between chunks.
ct.ThrowIfCancellationRequested();
int read = await reader.ReadAsync(buffer.AsMemory(), ct).ConfigureAwait(false);
if (read == 0)
{
// End of stream: null when nothing was read, otherwise the (unterminated) tail.
return builder.Length == 0 ? null : builder.ToString();
}
for (int i = 0; i < read; i++)
{
char c = buffer[i];
if (c == '\n')
{
return builder.ToString();
}
if (c == '\r')
{
continue;
}
if (builder.Length >= maxChars)
{
throw new InvalidDataException("CLI request line exceeded the maximum allowed length.");
}
builder.Append(c);
}
}
}
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>Routes a parsed CLI request to the matching command handler.</summary>
public static class CliRequestDispatcher
{
/// <summary>
/// Builds a serialized response from a request and a snapshot of the host application's state.
/// </summary>
public static async Task<string> BuildResponseAsync(
CliRequestEnvelope envelope,
IReadOnlyList<Monitor> snapshot,
IReadOnlySet<string> hiddenIds,
IReadOnlyList<CustomVcpValueMapping> customMappings,
IMonitorManager manager,
int defaultStep,
Func<CancellationToken, Task<PowerDisplayProfiles>> loadProfilesAsync,
Func<int, CancellationToken, Task<string?>> applyProfileAsync,
CancellationToken cancellationToken)
{
try
{
if (CliCommandHandlers.TryGet(envelope.Command, out var handler))
{
var context = new CliCommandContext(
envelope,
snapshot,
hiddenIds,
customMappings,
manager,
defaultStep,
loadProfilesAsync,
applyProfileAsync);
return await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
}
return CliResponse.SerializeError(
CliResponse.MakeCodedError(envelope.Command, CliErrorCodes.ArgumentError, CliMessageIds.UnknownCommand, value: envelope.Command));
}
catch (OperationCanceledException)
{
// Hardware writes cannot be rolled back, so cancellation is reported rather than
// returning a false success for a potentially partial operation.
return CliResponse.SerializeError(
CliResponse.MakeError(envelope.Command, CliErrorCodes.Timeout, "operation timed out or was cancelled"));
}
}
/// <summary>Creates a serialized timeout response for a request that was not dispatched.</summary>
public static string CreateTimeoutResponse()
=> CliResponse.SerializeError(
CliResponse.MakeError("unknown", CliErrorCodes.Timeout, "request timed out or was cancelled"));
/// <summary>Creates a serialized internal-error response for an invalid request envelope.</summary>
public static string CreateInvalidEnvelopeResponse()
=> CliResponse.SerializeError(
CliResponse.MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: "could not parse request envelope"));
/// <summary>Creates a serialized internal-error response for an unexpected host failure.</summary>
public static string CreateInternalErrorResponse(string detail)
=> CliResponse.SerializeError(
CliResponse.MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: detail));
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using PowerDisplay.Contracts;
namespace PowerDisplay.Ipc;
/// <summary>
/// Shared serialization + error-envelope helpers for the CLI IPC command handlers. Centralizing
/// these collapses the per-command "check the error, otherwise serialize the result" boilerplate the
/// dispatcher used to repeat for every command, so a new result DTO needs no new plumbing here.
/// </summary>
internal static class CliResponse
{
/// <summary>
/// Serializes a response DTO to one-line JSON using its source-generated
/// <see cref="JsonTypeInfo{T}"/> (AOT/trim safe).
/// </summary>
public static string Serialize<T>(T value, JsonTypeInfo<T> typeInfo)
=> JsonSerializer.Serialize(value, typeInfo);
/// <summary>Serializes a <see cref="CliErrorResult"/> envelope to one-line JSON.</summary>
public static string SerializeError(CliErrorResult error)
=> JsonSerializer.Serialize(error, ContractsJsonContext.Default.CliErrorResult);
/// <summary>
/// Serializes the populated half of a projector/executor <c>(Result, Error)</c> tuple: the error
/// envelope when present, otherwise the success DTO. Exactly one is expected to be non-null.
/// </summary>
public static string ResultOrError<T>((T? Result, CliErrorResult? Error) outcome, JsonTypeInfo<T> typeInfo)
where T : class
=> outcome.Error is not null ? SerializeError(outcome.Error) : Serialize(outcome.Result!, typeInfo);
/// <summary>
/// Free-text error: the app supplies the human-readable <paramref name="message"/> directly (used
/// for the internal/argument faults the CLI does not localize via a message id).
/// </summary>
public static CliErrorResult MakeError(string command, string code, string message)
=> new()
{
Command = command,
Error = new CliError
{
Code = code,
Message = message,
},
};
/// <summary>
/// Code-only error: the app names the message via <see cref="CliMessageIds"/> and supplies
/// structured data; the CLI localizes the human-readable text. Value/Detail feed the template.
/// </summary>
public static CliErrorResult MakeCodedError(string command, string code, string messageId, string? value = null, string? detail = null)
=> new()
{
Command = command,
Error = new CliError
{
Code = code,
MessageId = messageId,
Value = value,
Detail = detail,
},
};
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using PowerDisplay.Common.Drivers;
using PowerDisplay.Common.Models;
using PowerDisplay.Contracts;
namespace PowerDisplay.Ipc;
/// <summary>
/// The single source of per-setting VCP metadata for the CLI IPC layer. See <see cref="CliVcpSetting"/>.
/// Orientation is intentionally absent (it is GDI-based, not a VCP setting).
/// </summary>
internal static class CliSettingCatalog
{
/// <summary>The six VCP settings, in canonical (display) order.</summary>
public static readonly IReadOnlyList<CliVcpSetting> VcpSettings = new CliVcpSetting[]
{
new ContinuousVcpSetting(
CliSettingNames.Brightness,
NativeConstants.VcpCodeBrightness,
MonitorReadFlags.Brightness,
m => m.SupportsBrightness,
m => m.CurrentBrightness,
(mm, id, v, c) => mm.SetBrightnessAsync(id, v, c),
"monitor exposed neither a WMI brightness interface nor DDC/CI brightness (0x10)"),
new ContinuousVcpSetting(
CliSettingNames.Contrast,
NativeConstants.VcpCodeContrast,
MonitorReadFlags.Contrast,
m => m.SupportsContrast,
m => m.CurrentContrast,
(mm, id, v, c) => mm.SetContrastAsync(id, v, c),
"monitor's VCP capabilities did not advertise contrast (0x12)"),
new ContinuousVcpSetting(
CliSettingNames.Volume,
NativeConstants.VcpCodeVolume,
MonitorReadFlags.Volume,
m => m.SupportsVolume,
m => m.CurrentVolume,
(mm, id, v, c) => mm.SetVolumeAsync(id, v, c),
"monitor's VCP capabilities did not advertise audio speaker volume (0x62)"),
new DiscreteVcpSetting(
CliSettingNames.ColorTemperature,
NativeConstants.VcpCodeSelectColorPreset,
MonitorReadFlags.ColorTemperature,
m => m.SupportsColorTemperature,
m => m.CurrentColorTemperature,
m => m.VcpCapabilitiesInfo?.GetSupportedValues(NativeConstants.VcpCodeSelectColorPreset),
(mm, id, v, c) => mm.SetColorTemperatureAsync(id, v, c),
"monitor's VCP capabilities did not advertise color preset (0x14)"),
new DiscreteVcpSetting(
CliSettingNames.InputSource,
NativeConstants.VcpCodeInputSource,
MonitorReadFlags.InputSource,
m => m.SupportsInputSource,
m => m.CurrentInputSource,
m => m.SupportedInputSources,
(mm, id, v, c) => mm.SetInputSourceAsync(id, v, c),
"monitor's VCP capabilities did not advertise input source (0x60)"),
new DiscreteVcpSetting(
CliSettingNames.PowerState,
NativeConstants.VcpCodePowerMode,
MonitorReadFlags.PowerState,
m => m.SupportsPowerState,
m => m.CurrentPowerState,
m => m.SupportedPowerStates,
(mm, id, v, c) => mm.SetPowerStateAsync(id, v, c),
"monitor's VCP capabilities did not advertise power mode (0xD6)",
blanksDisplay: true),
};
private static readonly IReadOnlyDictionary<string, CliVcpSetting> ByNameMap =
VcpSettings.ToDictionary(s => s.Name, StringComparer.Ordinal);
/// <summary>
/// Returns the descriptor for a canonical (lower-case) setting name, or <see langword="null"/>
/// when the name is not one of the six VCP settings (e.g. <c>orientation</c> or an unknown name).
/// </summary>
public static CliVcpSetting? TryGet(string settingName)
=> ByNameMap.TryGetValue(settingName, out var setting) ? setting : null;
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Ipc;
/// <summary>Whether a VCP setting takes a continuous percentage or a discrete VCP value.</summary>
internal enum CliSettingKind
{
/// <summary>Percentage value in [0, 100] (brightness, contrast, volume).</summary>
Continuous,
/// <summary>Discrete VCP byte chosen from the monitor's advertised set (color-temperature, input-source, power-state).</summary>
Discrete,
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Linq;
namespace PowerDisplay.Ipc;
/// <summary>
/// Shared validation rules for CLI setting values, so the <c>set</c> command and the
/// <c>apply-profile</c> outcomes path validate identically and cannot drift.
/// </summary>
internal static class CliSettingValidation
{
/// <summary>
/// Returns whether a resolved discrete VCP value is acceptable for a monitor: it must be in the
/// monitor's advertised supported set when one is known. A null/empty set means the monitor did
/// not advertise its values, so the value is accepted (the hardware write is the final arbiter).
/// </summary>
public static bool IsDiscreteValueSupported(int value, IReadOnlyList<int>? supportedValues)
=> supportedValues is not { Count: > 0 } || supportedValues.Contains(value);
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// Polymorphic base for the per-setting metadata + behavior of one VCP setting the CLI read/write
/// commands operate on. The shared <em>data</em> (name, VCP code, read flag, capability/value/apply
/// delegates) lives here so <see cref="CliSettingCatalog"/> can still declare one row per setting;
/// the <em>behavior</em> that used to fan out into <c>Kind</c> switches in <see cref="SetCommandExecutor"/>
/// and <see cref="MonitorDtoProjector"/> — value parsing/validation, display formatting, and the
/// <c>set</c> pipeline — is dispatched here via <see cref="ContinuousVcpSetting"/> and
/// <see cref="DiscreteVcpSetting"/>.
/// <para>
/// <b>Orientation is intentionally excluded:</b> it is GDI-based (not a VCP code), needs a
/// <c>GdiDeviceName</c>, and maps degrees↔index, so it stays a special case at the call sites.
/// </para>
/// </summary>
internal abstract class CliVcpSetting
{
private readonly Func<Monitor, bool> _supports;
private readonly Func<Monitor, int> _current;
private readonly Func<IMonitorManager, string, int, CancellationToken, Task<MonitorOperationResult>> _apply;
/// <param name="name">Canonical (lower-case) setting name; see <c>CliSettingNames</c>.</param>
/// <param name="vcpCode">The VESA MCCS VCP code for this setting.</param>
/// <param name="readFlag">The <see cref="MonitorReadFlags"/> bit set when discovery read this setting.</param>
/// <param name="supports">Selects the monitor's hardware-capability flag for this setting.</param>
/// <param name="current">Selects the monitor's last-read value for this setting.</param>
/// <param name="apply">The hardware-write delegate for this setting on <see cref="IMonitorManager"/>.</param>
/// <param name="unsupportedReason">
/// Invariant English explanation surfaced when the monitor does not support this setting.
/// </param>
/// <param name="blanksDisplay">
/// True only for settings whose values can blank the panel (power-state); gates the
/// <c>--confirm-power-off</c> requirement.
/// </param>
protected CliVcpSetting(
string name,
byte vcpCode,
MonitorReadFlags readFlag,
Func<Monitor, bool> supports,
Func<Monitor, int> current,
Func<IMonitorManager, string, int, CancellationToken, Task<MonitorOperationResult>> apply,
string unsupportedReason,
bool blanksDisplay = false)
{
Name = name;
VcpCode = vcpCode;
ReadFlag = readFlag;
_supports = supports;
_current = current;
_apply = apply;
UnsupportedReason = unsupportedReason;
BlanksDisplay = blanksDisplay;
}
/// <summary>Canonical (lower-case) setting name; see <c>CliSettingNames</c>.</summary>
public string Name { get; }
/// <summary>The VESA MCCS VCP code for this setting.</summary>
public byte VcpCode { get; }
/// <summary>The <see cref="MonitorReadFlags"/> bit set when discovery read this setting.</summary>
public MonitorReadFlags ReadFlag { get; }
/// <summary>Invariant English explanation surfaced when the monitor does not support this setting.</summary>
public string UnsupportedReason { get; }
/// <summary>True only for settings whose values can blank the panel (power-state).</summary>
public bool BlanksDisplay { get; }
/// <summary>Continuous percentage vs. discrete VCP value.</summary>
public abstract CliSettingKind Kind { get; }
/// <summary>Whether the monitor advertises hardware support for this setting.</summary>
public bool Supports(Monitor monitor) => _supports(monitor);
/// <summary>The monitor's last-read value for this setting.</summary>
public int Current(Monitor monitor) => _current(monitor);
/// <summary>Performs the DDC/CI or WMI hardware write for this setting.</summary>
public Task<MonitorOperationResult> Apply(IMonitorManager manager, string monitorId, int value, CancellationToken ct)
=> _apply(manager, monitorId, value, ct);
/// <summary>
/// The monitor's advertised discrete value set (used to validate a <c>set</c> value).
/// <see langword="null"/> for continuous settings, which have no discrete set.
/// </summary>
public virtual IReadOnlyList<int>? SupportedValues(Monitor monitor) => null;
/// <summary>
/// Parses and validates the raw <c>set</c> value against this setting's rules and the target
/// <paramref name="monitor"/>. Returns the resolved value with a <see langword="null"/> error, or
/// a <see langword="null"/> value with the <see cref="CliError"/> to surface (the caller wraps it
/// in a <see cref="CliErrorResult"/> with the monitor ref).
/// </summary>
public abstract (int? Value, CliError? Error) ParseSetValue(string rawValue, Monitor monitor);
/// <summary>Formats a value as the human-readable before/after display string for this setting.</summary>
public abstract string FormatDisplay(int value, IReadOnlyList<CustomVcpValueMapping>? customMappings = null, string monitorId = "");
/// <summary>
/// Shared <c>set</c> pipeline (template method): capability check → value parse/validate →
/// panel-blanking gate → hardware write → cancellation/failure handling → before/after result.
/// The per-kind steps (<see cref="ParseSetValue"/>, <see cref="FormatDisplay"/>, and the
/// <see cref="BlanksDisplay"/> data) are the only points that vary between continuous and discrete
/// settings, so the ordering and error contract live here once.
/// </summary>
public async Task<(CliSetResult? Result, CliErrorResult? Error)> ApplySetAsync(
IMonitorManager manager,
Monitor monitor,
CliMonitorRef monitorRef,
SetRequest req,
CancellationToken ct,
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
{
if (!Supports(monitor))
{
return (null, CliErrorFactory.Unsupported(CliCommandNames.Set, monitorRef, Name, UnsupportedReason));
}
var (value, parseError) = ParseSetValue(req.RawValue, monitor);
if (parseError is not null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Set, Monitor = monitorRef, Error = parseError });
}
// Gate any state that blanks the panel on the already-resolved value. Only power-state sets
// BlanksDisplay, so continuous settings skip this unconditionally.
if (BlanksDisplay && !req.ConfirmPowerOff && IsDisplayBlanking(value!.Value))
{
return (null, new CliErrorResult
{
Command = CliCommandNames.Set,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.PowerBlankingConfirm,
Setting = Name,
},
});
}
var beforeKnown = monitor.ReadValues.HasFlag(ReadFlag);
var beforeValue = Current(monitor);
var op = await Apply(manager, monitor.Id, value!.Value, ct);
// The server receives its own app-lifetime token; a client Ctrl+C/deadline only closes the
// pipe and is not propagated here. If server shutdown is observed before or after the
// non-interruptible hardware call, surface the cancellation as TIMEOUT when a response can
// still be returned.
ct.ThrowIfCancellationRequested();
if (!op.IsSuccess)
{
return (null, CliErrorFactory.HardwareFailure(CliCommandNames.Set, monitorRef, op.ErrorMessage));
}
return (new CliSetResult
{
Monitor = monitorRef,
Setting = Name,
BeforeDisplay = beforeKnown ? FormatDisplay(beforeValue, customMappings, monitor.Id) : null,
AfterDisplay = FormatDisplay(value.Value, customMappings, monitor.Id),
}, null);
}
/// <summary>
/// VCP 0xD6 states that leave a headless caller staring at a dark panel.
/// </summary>
protected static bool IsDisplayBlanking(int powerState) => powerState is 0x02 or 0x03 or 0x04 or 0x05;
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// A continuous percentage VCP setting (brightness, contrast, volume). The <c>set</c> value is a
/// plain integer that must fall in [0, 100]; the display string is a bare percentage.
/// </summary>
internal sealed class ContinuousVcpSetting : CliVcpSetting
{
private const int Min = 0;
private const int Max = 100;
public ContinuousVcpSetting(
string name,
byte vcpCode,
MonitorReadFlags readFlag,
Func<Monitor, bool> supports,
Func<Monitor, int> current,
Func<IMonitorManager, string, int, CancellationToken, Task<MonitorOperationResult>> apply,
string unsupportedReason)
: base(name, vcpCode, readFlag, supports, current, apply, unsupportedReason)
{
}
public override CliSettingKind Kind => CliSettingKind.Continuous;
/// <summary>
/// Parses the raw value as an integer (the app receives it as a string from the JSON request),
/// then validates it is within [0, 100].
/// </summary>
public override (int? Value, CliError? Error) ParseSetValue(string rawValue, Monitor monitor)
{
if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var requested))
{
return (null, new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.InvalidInteger,
Value = rawValue,
Setting = Name,
});
}
if (requested < Min || requested > Max)
{
return (null, new CliError
{
Code = CliErrorCodes.OutOfRange,
MessageId = CliMessageIds.OutOfRange,
ExpectedRange = $"[{Min}, {Max}]",
Value = requested.ToString(CultureInfo.InvariantCulture),
Setting = Name,
});
}
return (requested, null);
}
public override string FormatDisplay(int value, IReadOnlyList<CustomVcpValueMapping>? customMappings = null, string monitorId = "")
=> value + "%";
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Common.Utils;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// A discrete VCP setting (color-temperature, input-source, power-state). The <c>set</c> value is a
/// hex literal (<c>0x??</c>) that must be present in the monitor's advertised supported set; the
/// display string is the "Name (0xNN)" form.
/// <para>
/// Friendly names are intentionally NOT accepted: the generic VCP name table can disagree with a
/// specific monitor's value mapping, so the CLI requires an unambiguous hex value (use
/// <c>capabilities --setting &lt;name&gt;</c> to discover them).
/// </para>
/// </summary>
internal sealed class DiscreteVcpSetting : CliVcpSetting
{
private readonly Func<Monitor, IReadOnlyList<int>?> _supportedValues;
public DiscreteVcpSetting(
string name,
byte vcpCode,
MonitorReadFlags readFlag,
Func<Monitor, bool> supports,
Func<Monitor, int> current,
Func<Monitor, IReadOnlyList<int>?> supportedValues,
Func<IMonitorManager, string, int, CancellationToken, Task<MonitorOperationResult>> apply,
string unsupportedReason,
bool blanksDisplay = false)
: base(name, vcpCode, readFlag, supports, current, apply, unsupportedReason, blanksDisplay)
{
_supportedValues = supportedValues;
}
public override CliSettingKind Kind => CliSettingKind.Discrete;
public override IReadOnlyList<int>? SupportedValues(Monitor monitor) => _supportedValues(monitor);
/// <summary>
/// Resolves a discrete VCP value from a hex literal (<c>0x??</c>), then verifies it against the
/// monitor's supported set.
/// </summary>
public override (int? Value, CliError? Error) ParseSetValue(string rawValue, Monitor monitor)
{
var supportedValues = _supportedValues(monitor);
if (string.IsNullOrWhiteSpace(rawValue))
{
return (null, MakeParseError(rawValue, supportedValues));
}
int? parsedValue = TryParseHex(rawValue);
if (parsedValue is null)
{
return (null, MakeParseError(rawValue, supportedValues));
}
// If the monitor reports a supported-value set, the resolved value must be in it.
if (!CliSettingValidation.IsDiscreteValueSupported(parsedValue.Value, supportedValues))
{
return (null, MakeUnsupportedError(rawValue, supportedValues!));
}
return (parsedValue, null);
}
public override string FormatDisplay(int value, IReadOnlyList<CustomVcpValueMapping>? customMappings = null, string monitorId = "")
=> MonitorDtoProjector.FormatDiscrete(VcpCode, value, customMappings, monitorId);
/// <summary>
/// Parses a hex literal of the form "0x??".
/// </summary>
private static int? TryParseHex(string raw)
{
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(raw[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex)
&& hex is >= 0x00 and <= 0xFF)
{
return hex;
}
return null;
}
private CliError MakeParseError(string raw, IReadOnlyList<int>? supportedValues)
=> new()
{
Code = CliErrorCodes.InvalidDiscreteValue,
MessageId = CliMessageIds.InvalidDiscrete,
Supported = BuildSupportedList(VcpCode, supportedValues),
Value = raw,
Setting = Name,
};
private CliError MakeUnsupportedError(string raw, IReadOnlyList<int> supportedValues)
=> new()
{
Code = CliErrorCodes.InvalidDiscreteValue,
MessageId = CliMessageIds.DiscreteNotInSet,
Supported = BuildSupportedList(VcpCode, supportedValues),
Value = raw,
Setting = Name,
};
private static IReadOnlyList<CliSupportedValue>? BuildSupportedList(byte vcpCode, IReadOnlyList<int>? values)
{
if (values is null || values.Count == 0)
{
return null;
}
var list = new List<CliSupportedValue>(values.Count);
for (int i = 0; i < values.Count; i++)
{
var v = values[i];
list.Add(new CliSupportedValue
{
Name = VcpNames.GetValueName(vcpCode, v) ?? $"0x{v:X2}",
Vcp = $"0x{v:X2}",
});
}
return list;
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PowerDisplay.Ipc;
/// <summary>
/// Handles one CLI IPC command (list, get, set, …). Each handler owns the payload check, the
/// projector/executor call, and the response serialization for its command, so the dispatcher only
/// routes by command name instead of carrying a per-command switch arm.
/// </summary>
internal interface ICliCommandHandler
{
/// <summary>
/// Executes the command against the pre-fetched <paramref name="context"/> and returns the
/// one-line JSON response. Expected error conditions are returned as an error envelope rather than
/// thrown. The dispatcher passes the server's app-lifetime token; client Ctrl+C/deadlines are
/// local to the CLI, only close the pipe, and are not propagated to handlers. If server shutdown
/// is observed before or after a non-interruptible hardware call, the handler maps that
/// cancellation to <c>TIMEOUT</c> when it can still return a response.
/// </summary>
/// <param name="context">The per-request snapshot of ViewModel/MonitorManager state.</param>
/// <param name="ct">The server's app-lifetime cancellation token.</param>
Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct);
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Ipc;
/// <summary>Logging boundary supplied by the hosting application.</summary>
public interface ICliLogger
{
/// <summary>Logs an informational message.</summary>
void LogInfo(string message);
/// <summary>Logs a warning message.</summary>
void LogWarning(string message);
/// <summary>Logs an error message.</summary>
void LogError(string message);
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading;
using System.Threading.Tasks;
namespace PowerDisplay.Ipc;
/// <summary>Processes one serialized CLI request and returns its serialized response.</summary>
public interface ICliRequestProcessor
{
/// <summary>Processes a single request from the named-pipe server.</summary>
Task<string> HandleAsync(string requestJson, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,424 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Utils;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// Pure-function projector that turns the app's rich <see cref="Monitor"/> model into the flat
/// Contracts result DTOs consumed by the CLI renderers. All three read-side commands (list, get,
/// capabilities) are covered.
/// <para>
/// This projector is the single source of these DTOs: it defines the display strings, error
/// codes/exit codes, and hidden-monitor and selector semantics that the CLI renderers consume.
/// </para>
/// </summary>
public static class MonitorDtoProjector
{
// ─── Public API ────────────────────────────────────────────────────────────
/// <summary>
/// Builds the result DTO for the <c>list</c> command.
/// Hidden monitors are excluded; each surviving monitor becomes one list entry.
/// </summary>
public static CliListResult BuildListResult(
IReadOnlyList<Monitor> monitors,
IReadOnlySet<string> hiddenIds)
{
var visible = ExcludeHidden(monitors, hiddenIds);
var entries = new List<CliMonitorRef>(visible.Count);
foreach (var m in visible)
{
entries.Add(ToRef(m));
}
return new CliListResult { Monitors = entries };
}
/// <summary>
/// Builds the result DTO for the <c>get</c> command.
/// <list type="bullet">
/// <item>When no selector (<paramref name="number"/> and <paramref name="id"/> both null/empty)
/// all visible monitors are returned.</item>
/// <item>Otherwise the selector is resolved; if resolution fails an error DTO is returned.</item>
/// <item>An unknown <paramref name="settingFilter"/> yields an <c>ARGUMENT_ERROR</c> error DTO.</item>
/// </list>
/// </summary>
public static (CliGetResult? Result, CliErrorResult? Error) BuildGetResult(
IReadOnlyList<Monitor> monitors,
IReadOnlySet<string> hiddenIds,
int? number,
string? id,
string? settingFilter,
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
{
var visible = ExcludeHidden(monitors, hiddenIds);
if (!number.HasValue && string.IsNullOrEmpty(id))
{
if (TryGetUnknownSettingError(settingFilter, out _, out var settingErr))
{
return (null, new CliErrorResult { Command = CliCommandNames.Get, Error = settingErr! });
}
var allEntries = new List<CliGetMonitorEntry>(visible.Count);
foreach (var monitor in visible)
{
var monRef = ToRef(monitor);
allEntries.Add(BuildGetEntry(monitor, monRef, settingFilter, customMappings, out _)!);
}
return (new CliGetResult { Monitors = allEntries }, null);
}
var (selected, resolveError) = ResolveMonitor(visible, number, id);
if (resolveError is not null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Get, Error = resolveError });
}
var mRef = ToRef(selected!);
var entry = BuildGetEntry(selected!, mRef, settingFilter, customMappings, out var settingError);
if (settingError is not null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Get, Monitor = mRef, Error = settingError });
}
return (new CliGetResult { Monitors = [entry!] }, null);
}
/// <summary>
/// Builds the result DTO for the <c>capabilities</c> command.
/// A selector is required; if missing or not found an error DTO is returned.
/// </summary>
public static (CliCapabilitiesResult? Result, CliErrorResult? Error) BuildCapabilitiesResult(
IReadOnlyList<Monitor> monitors,
IReadOnlySet<string> hiddenIds,
int? number,
string? id,
string? settingFilter = null,
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
{
var visible = ExcludeHidden(monitors, hiddenIds);
var (selected, resolveError) = ResolveMonitor(visible, number, id);
if (resolveError is not null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Capabilities, Error = resolveError });
}
// Optional --setting filter: restrict the result to a single discrete setting's VCP code.
byte? filterCode = null;
if (settingFilter is not null)
{
filterCode = VcpCodeForDiscreteSetting(settingFilter);
if (filterCode is null)
{
return (null, new CliErrorResult
{
Command = CliCommandNames.Capabilities,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.NotDiscreteSetting,
Value = settingFilter,
},
});
}
}
var caps = selected!.VcpCapabilitiesInfo;
var vcpCodes = new List<CliVcpCodeInfo>();
if (caps is not null)
{
foreach (var code in caps.GetSortedVcpCodes())
{
if (filterCode is not null && code.Code != filterCode.Value)
{
continue;
}
List<string>? discreteValues = null;
if (code.HasDiscreteValues)
{
discreteValues = new List<string>(code.SupportedValues.Count);
foreach (var v in code.SupportedValues)
{
discreteValues.Add(FormatDiscrete(code.Code, v, customMappings, selected.Id));
}
}
vcpCodes.Add(new CliVcpCodeInfo
{
Code = code.FormattedCode,
Name = code.Name,
Continuous = code.IsContinuous,
DiscreteValues = discreteValues,
});
}
}
return (new CliCapabilitiesResult
{
// Transport lives in the dedicated top-level CommunicationMethod below, so leave
// Method off the monitor ref (it is omitted from JSON) to avoid emitting the same
// value twice in the capabilities envelope.
Monitor = new CliMonitorRef
{
Number = selected!.MonitorNumber,
Id = selected!.Id,
Name = selected!.Name,
},
CommunicationMethod = selected!.CommunicationMethod,
RawCapabilities = selected!.CapabilitiesRaw,
Model = caps?.Model,
MccsVersion = caps?.MccsVersion,
VcpCodes = vcpCodes,
}, null);
}
// ─── Internal helpers (visible for testing) ────────────────────────────────
/// <summary>
/// Drops monitors the user hid in PowerDisplay settings.
/// </summary>
internal static IReadOnlyList<Monitor> ExcludeHidden(
IReadOnlyList<Monitor> monitors,
IReadOnlySet<string> hiddenIds)
{
if (hiddenIds.Count == 0)
{
return monitors;
}
var kept = new List<Monitor>(monitors.Count);
foreach (var m in monitors)
{
if (!hiddenIds.Contains(m.Id))
{
kept.Add(m);
}
}
return kept;
}
/// <summary>
/// Resolves the target monitor from the already-filtered list using CLI selector semantics.
/// <list type="bullet">
/// <item>No selector → <c>SelectorMissing</c> error.</item>
/// <item>Both selectors → id wins (the CLI surfaces the "-n ignored" note locally).</item>
/// <item>Not found → <c>MonitorNotFound</c> error.</item>
/// </list>
/// </summary>
internal static (Monitor? Monitor, CliError? Error) ResolveMonitor(
IReadOnlyList<Monitor> monitors,
int? monitorNumber,
string? monitorId)
{
var hasNumber = monitorNumber.HasValue;
var hasId = !string.IsNullOrEmpty(monitorId);
if (!hasNumber && !hasId)
{
return (null, new CliError
{
Code = CliErrorCodes.SelectorMissing,
MessageId = CliMessageIds.SelectorMissing,
});
}
if (hasId)
{
for (int i = 0; i < monitors.Count; i++)
{
if (string.Equals(monitors[i].Id, monitorId, StringComparison.OrdinalIgnoreCase))
{
return (monitors[i], null);
}
}
return (null, new CliError
{
Code = CliErrorCodes.MonitorNotFound,
MessageId = CliMessageIds.MonitorNotFoundId,
Value = monitorId,
});
}
var number = monitorNumber!.GetValueOrDefault();
for (int i = 0; i < monitors.Count; i++)
{
if (monitors[i].MonitorNumber == number)
{
return (monitors[i], null);
}
}
return (null, new CliError
{
Code = CliErrorCodes.MonitorNotFound,
MessageId = CliMessageIds.MonitorNotFoundNumber,
Value = number.ToString(CultureInfo.InvariantCulture),
});
}
// ─── Private helpers ───────────────────────────────────────────────────────
/// <summary>Builds the compact monitor reference embedded in every response. Shared with <see cref="SetCommandExecutor"/>.</summary>
internal static CliMonitorRef ToRef(Monitor m) => new()
{
Number = m.MonitorNumber,
Id = m.Id,
Name = m.Name,
Method = m.CommunicationMethod,
};
/// <summary>
/// Builds the per-monitor <c>get</c> entry. Returns null and sets <paramref name="error"/> when the
/// setting filter names an unknown setting.
/// </summary>
private static CliGetMonitorEntry? BuildGetEntry(
Monitor monitor,
CliMonitorRef monitorRef,
string? settingFilter,
IReadOnlyList<CustomVcpValueMapping>? customMappings,
out CliError? error)
{
if (TryGetUnknownSettingError(settingFilter, out var normalizedFilter, out error))
{
return null;
}
IEnumerable<string> settingNames = normalizedFilter is null
? CliSettingNames.All
: new[] { normalizedFilter };
var results = new List<CliSettingValue>();
foreach (var name in settingNames)
{
results.Add(BuildSettingValue(monitor, name, customMappings)!);
}
return new CliGetMonitorEntry
{
Monitor = monitorRef,
Settings = results,
};
}
/// <summary>
/// Validates the optional <c>--setting</c> filter against <see cref="CliSettingNames.All"/>.
/// Returns <c>true</c> with a populated error when the filter names an unknown setting.
/// The error echoes the user's original input verbatim, not the lower-cased lookup key.
/// </summary>
private static bool TryGetUnknownSettingError(string? settingFilter, out string? normalized, out CliError? error)
{
error = null;
normalized = settingFilter?.ToLowerInvariant();
if (settingFilter is null || Array.IndexOf(CliSettingNames.All, normalized) >= 0)
{
return false;
}
error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.UnknownSetting,
Value = settingFilter,
};
return true;
}
/// <summary>
/// Projects one setting value.
/// The value is reported only when the monitor both supports it and discovery
/// actually read it (<see cref="Monitor.ReadValues"/>) — a default/stale field is
/// never passed off as a live reading.
/// </summary>
private static CliSettingValue? BuildSettingValue(Monitor monitor, string settingName, IReadOnlyList<CustomVcpValueMapping>? customMappings)
{
// Orientation is GDI-based (not a VCP setting), so it is not in the catalog. The raw value is
// the orientation index; Reading formats it via OrientationDegrees only when it was actually
// read.
if (settingName == CliSettingNames.Orientation)
{
return Reading(
CliSettingNames.Orientation,
!string.IsNullOrEmpty(monitor.GdiDeviceName),
monitor.ReadValues.HasFlag(MonitorReadFlags.Orientation),
monitor.Orientation,
OrientationDegrees);
}
var setting = CliSettingCatalog.TryGet(settingName);
if (setting is null)
{
return null;
}
return Reading(
setting.Name,
setting.Supports(monitor),
monitor.ReadValues.HasFlag(setting.ReadFlag),
setting.Current(monitor),
v => setting.FormatDisplay(v, customMappings, monitor.Id));
}
/// <summary>
/// Projects one setting, gating the value on supported &amp;&amp; read.
/// </summary>
private static CliSettingValue Reading(string name, bool supported, bool read, int raw, Func<int, string> format)
{
var known = supported && read;
return new CliSettingValue
{
Setting = name,
Supported = supported,
Display = known ? format(raw) : null,
};
}
/// <summary>
/// Formats a discrete VCP value as "Name (0xNN)" or "0xNN" when the name is unknown.
/// </summary>
internal static string FormatDiscrete(byte vcpCode, int value, IReadOnlyList<CustomVcpValueMapping>? customMappings = null, string monitorId = "")
{
var name = VcpNames.GetValueName(vcpCode, value, customMappings, monitorId);
return name is null
? $"0x{value:X2}"
: $"{name} (0x{value:X2})";
}
/// <summary>
/// Maps a CLI discrete setting name to its VCP code, or null when the name is not one of the
/// three discrete VCP settings (color-temperature 0x14, input-source 0x60, power-state 0xD6).
/// </summary>
internal static byte? VcpCodeForDiscreteSetting(string setting)
{
var descriptor = CliSettingCatalog.TryGet(setting.ToLowerInvariant());
return descriptor is { Kind: CliSettingKind.Discrete } ? descriptor.VcpCode : null;
}
/// <summary>
/// Returns the human-readable orientation string for a GDI orientation index (03).
/// </summary>
internal static string OrientationDegrees(int index) => index switch
{
0 => "0°",
1 => "90°",
2 => "180°",
3 => "270°",
_ => $"index {index}",
};
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Ipc;
internal sealed class NullCliLogger : ICliLogger
{
public static NullCliLogger Instance { get; } = new();
private NullCliLogger()
{
}
public void LogInfo(string message)
{
}
public void LogWarning(string message)
{
}
public void LogError(string message)
{
}
}

View File

@@ -0,0 +1,32 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../../Common.Dotnet.CsWinRT.props" />
<Import Project="../../../Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>PowerDisplay.Ipc</RootNamespace>
<AssemblyName>PowerToys.PowerDisplay.Ipc</AssemblyName>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="PowerDisplay.Ipc.UnitTests" />
</ItemGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../PowerDisplay.Contracts/PowerDisplay.Contracts.csproj" />
<ProjectReference Include="../PowerDisplay.Lib/PowerDisplay.Lib.csproj" />
<ProjectReference Include="../PowerDisplay.Models/PowerDisplay.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
namespace PowerDisplay.Ipc;
/// <summary>
/// Pure-function projector that converts the app's profile model into the flat Contracts DTO used
/// by the CLI IPC renderer for the <c>profiles</c> command.
/// </summary>
public static class ProfileDtoProjector
{
// ─── profiles (list) ──────────────────────────────────────────────────────
/// <summary>
/// Builds the result DTO for the <c>profiles</c> command.
/// Projects each <see cref="PowerDisplayProfile"/> in <paramref name="profiles"/> to a
/// <see cref="CliProfileInfo"/>:
/// <list type="bullet">
/// <item><see cref="CliProfileInfo.Id"/> — profile id.</item>
/// <item><see cref="CliProfileInfo.Name"/> — profile name.</item>
/// <item><see cref="CliProfileInfo.MonitorCount"/> — <c>MonitorSettings.Count</c>.</item>
/// <item><see cref="CliProfileInfo.LastModified"/> — <see cref="PowerDisplayProfile.LastModified"/>
/// formatted as ISO 8601 round-trip ("o") with invariant culture.</item>
/// </list>
/// </summary>
/// <param name="profiles">The loaded profiles collection (may be empty; must not be null).</param>
public static CliProfileListResult BuildProfileListResult(PowerDisplayProfiles profiles)
{
ArgumentNullException.ThrowIfNull(profiles);
var infos = new List<CliProfileInfo>(profiles.Profiles.Count);
foreach (var profile in profiles.GetAssignedProfiles())
{
infos.Add(new CliProfileInfo
{
Id = profile.Id,
Name = profile.Name ?? string.Empty,
MonitorCount = profile.MonitorSettings?.Count ?? 0,
// ISO 8601 round-trip ("o") with invariant culture.
LastModified = profile.LastModified.ToString("o", CultureInfo.InvariantCulture),
});
}
return new CliProfileListResult { Profiles = infos };
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Contracts;
using PowerDisplay.Models;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.Ipc;
/// <summary>
/// App-side executor for the <c>set</c> IPC command. Validates a <see cref="SetRequest"/>
/// against the target monitor's hardware capabilities, performs the DDC/CI or GDI write via
/// <see cref="IMonitorManager"/>, and returns a Contracts result tuple.
/// <para>
/// The "exactly one setting" syntactic check is intentionally omitted — that stays in the CLI
/// argument parser. By the time <see cref="ExecuteAsync"/> is called, <see cref="SetRequest.Setting"/>
/// already names the single target setting.
/// </para>
/// <para>
/// Defines the validation order and exit-code mapping for the <c>set</c> command. Errors carry a
/// <see cref="CliError.Code"/> + <see cref="CliError.MessageId"/> + structured fields only; the CLI
/// owns and localizes the human-readable text (see <c>CliErrorLocalizer</c>).
/// </para>
/// </summary>
public static class SetCommandExecutor
{
// ─── Public API ────────────────────────────────────────────────────────────
/// <summary>
/// Validates <paramref name="req"/> and executes the hardware write.
/// </summary>
/// <param name="manager">The app's live monitor manager.</param>
/// <param name="snapshot">Pre-discovered monitor list (already filtered by the caller if needed).</param>
/// <param name="hidden">Set of monitor IDs hidden by user preference.</param>
/// <param name="req">The set request from the CLI IPC channel.</param>
/// <param name="customMappings">User-defined names for discrete VCP values.</param>
/// <param name="ct">
/// The server's app-lifetime cancellation token. Client Ctrl+C/deadlines are local to the CLI,
/// only close the pipe, and are not propagated to handlers.
/// </param>
/// <returns>
/// Exactly one of <c>Result</c> or <c>Error</c> is non-null.
/// </returns>
public static async Task<(CliSetResult? Result, CliErrorResult? Error)> ExecuteAsync(
IMonitorManager manager,
IReadOnlyList<Monitor> snapshot,
IReadOnlySet<string> hidden,
SetRequest req,
CancellationToken ct,
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
{
// --- 1. Exclude hidden monitors ---
var visible = MonitorDtoProjector.ExcludeHidden(snapshot, hidden);
// --- 2. Resolve the target monitor ---
var (monitor, resolveError) = MonitorDtoProjector.ResolveMonitor(visible, req.MonitorNumber, req.MonitorId);
if (resolveError is not null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Set, Error = resolveError });
}
var monitorRef = MonitorDtoProjector.ToRef(monitor!);
var setting = req.Setting?.Trim().ToLowerInvariant() ?? string.Empty;
// --- 3. Dispatch to the per-setting handler ---
// Orientation is GDI-based (not a VCP setting), so it is not in the catalog.
if (setting == CliSettingNames.Orientation)
{
return await ApplyOrientationAsync(manager, monitor!, monitorRef, req.RawValue, ct);
}
var descriptor = CliSettingCatalog.TryGet(setting);
if (descriptor is null)
{
return (null, new CliErrorResult
{
Command = CliCommandNames.Set,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
MessageId = CliMessageIds.UnknownSetting,
Value = req.Setting,
},
});
}
// Continuous vs. discrete parsing, validation, formatting, and the panel-blanking gate are
// dispatched polymorphically by the descriptor's ApplySetAsync template method.
return await descriptor.ApplySetAsync(manager, monitor!, monitorRef, req, ct, customMappings);
}
// ─── Orientation ──────────────────────────────────────────────────────────
private static async Task<(CliSetResult? Result, CliErrorResult? Error)> ApplyOrientationAsync(
IMonitorManager manager,
Monitor monitor,
CliMonitorRef monitorRef,
string rawValue,
CancellationToken ct)
{
if (string.IsNullOrEmpty(monitor.GdiDeviceName))
{
return (null, new CliErrorResult
{
Command = CliCommandNames.Set,
Monitor = monitorRef,
Error = new CliError
{
Code = CliErrorCodes.UnsupportedFeature,
MessageId = CliMessageIds.Unsupported,
Setting = CliSettingNames.Orientation,
Detail = "no GDI device name",
},
});
}
var index = TryResolveOrientation(rawValue, out var error);
if (index is null)
{
return (null, new CliErrorResult { Command = CliCommandNames.Set, Monitor = monitorRef, Error = error! });
}
var beforeIndex = monitor.Orientation;
var beforeKnown = monitor.ReadValues.HasFlag(MonitorReadFlags.Orientation);
var op = await manager.SetRotationAsync(monitor.Id, index.Value, ct);
// The server receives its own app-lifetime token; a client Ctrl+C/deadline only closes the
// pipe and is not propagated here. If server shutdown is observed before or after the
// non-interruptible rotation call, surface the cancellation as TIMEOUT when a response can
// still be returned.
ct.ThrowIfCancellationRequested();
if (!op.IsSuccess)
{
return (null, CliErrorFactory.HardwareFailure(CliCommandNames.Set, monitorRef, op.ErrorMessage));
}
return (new CliSetResult
{
Monitor = monitorRef,
Setting = CliSettingNames.Orientation,
BeforeDisplay = beforeKnown ? MonitorDtoProjector.OrientationDegrees(beforeIndex) : null,
AfterDisplay = MonitorDtoProjector.OrientationDegrees(index.Value),
}, null);
}
// ─── Value validation / resolution ─────────────────────────────────────────
/// <summary>
/// Resolves an orientation degree string (0, 90, 180, 270) into a GDI index (03).
/// </summary>
private static int? TryResolveOrientation(string raw, out CliError? error)
{
error = null;
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var degrees))
{
error = MakeOrientationError(raw);
return null;
}
switch (degrees)
{
case 0: return 0;
case 90: return 1;
case 180: return 2;
case 270: return 3;
default:
error = MakeOrientationError(raw);
return null;
}
}
// ─── Shared CliError factory helpers ─────────────────────────────────────
private static CliError MakeOrientationError(string raw)
{
return new CliError
{
Code = CliErrorCodes.InvalidDiscreteValue,
MessageId = CliMessageIds.InvalidOrientation,
Value = raw,
};
}
}

View File

@@ -444,6 +444,7 @@ namespace PowerDisplay.Common.Drivers.DDC
if (TryGetVcpFeature(handle, VcpCodeInputSource, monitor.Id, out uint current, out uint _))
{
monitor.CurrentInputSource = (int)current;
monitor.ReadValues |= MonitorReadFlags.InputSource;
}
}
@@ -455,6 +456,7 @@ namespace PowerDisplay.Common.Drivers.DDC
if (TryGetVcpFeature(handle, VcpCodeSelectColorPreset, monitor.Id, out uint current, out uint _))
{
monitor.CurrentColorTemperature = (int)current;
monitor.ReadValues |= MonitorReadFlags.ColorTemperature;
}
}
@@ -466,6 +468,7 @@ namespace PowerDisplay.Common.Drivers.DDC
if (TryGetVcpFeature(handle, VcpCodePowerMode, monitor.Id, out uint current, out uint _))
{
monitor.CurrentPowerState = (int)current;
monitor.ReadValues |= MonitorReadFlags.PowerState;
}
}
@@ -487,6 +490,7 @@ namespace PowerDisplay.Common.Drivers.DDC
monitor.BrightnessVcpMax = (int)max;
monitor.CurrentBrightness = brightnessInfo.ToPercentage();
monitor.ReadValues |= MonitorReadFlags.Brightness;
}
}
@@ -498,9 +502,17 @@ namespace PowerDisplay.Common.Drivers.DDC
{
if (TryGetVcpFeature(handle, VcpCodeContrast, monitor.Id, out uint current, out uint max))
{
monitor.ContrastVcpMax = (int)max;
var contrastInfo = new VcpFeatureValue((int)current, 0, (int)max);
if (!contrastInfo.IsValid)
{
Logger.LogWarning(
$"DDC: [{monitor.Id}] Ignoring invalid contrast range current={current}, max={max}");
return;
}
monitor.ContrastVcpMax = (int)max;
monitor.CurrentContrast = contrastInfo.ToPercentage();
monitor.ReadValues |= MonitorReadFlags.Contrast;
}
}
@@ -512,9 +524,17 @@ namespace PowerDisplay.Common.Drivers.DDC
{
if (TryGetVcpFeature(handle, VcpCodeVolume, monitor.Id, out uint current, out uint max))
{
monitor.VolumeVcpMax = (int)max;
var volumeInfo = new VcpFeatureValue((int)current, 0, (int)max);
if (!volumeInfo.IsValid)
{
Logger.LogWarning(
$"DDC: [{monitor.Id}] Ignoring invalid volume range current={current}, max={max}");
return;
}
monitor.VolumeVcpMax = (int)max;
monitor.CurrentVolume = volumeInfo.ToPercentage();
monitor.ReadValues |= MonitorReadFlags.Volume;
}
}

Some files were not shown because too many files have changed in this diff Show More