mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-07 19:09:43 +02:00
Compare commits
56 Commits
workspaces
...
yuleng/pd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47bbfcfe3 | ||
|
|
1e1c15cd9c | ||
|
|
aee4de7f1a | ||
|
|
d22ffd9224 | ||
|
|
8770d9e5e2 | ||
|
|
3fa8d0e266 | ||
|
|
787c6baa17 | ||
|
|
f6a83d255e | ||
|
|
ae4900050f | ||
|
|
cb47379652 | ||
|
|
b86a40b20a | ||
|
|
be4920a9aa | ||
|
|
4806024238 | ||
|
|
b05e09fb06 | ||
|
|
bfadf02dd9 | ||
|
|
454d0cad9c | ||
|
|
80a3fd3722 | ||
|
|
89235a93dd | ||
|
|
b8c314c23a | ||
|
|
9d35c8dd56 | ||
|
|
dc66d7f2c6 | ||
|
|
5a94c9ba42 | ||
|
|
6533234a34 | ||
|
|
c39e3e313a | ||
|
|
932f1b511e | ||
|
|
0dcfde849d | ||
|
|
00465ca21a | ||
|
|
e8a00f5fdc | ||
|
|
8475d55b8a | ||
|
|
3e3b3df23c | ||
|
|
cf9034d33e | ||
|
|
57d9c9b011 | ||
|
|
b7e0e9237a | ||
|
|
7e68851679 | ||
|
|
b456f45b02 | ||
|
|
b6880cbcf0 | ||
|
|
002afa2261 | ||
|
|
f798f5838c | ||
|
|
2b85da37fa | ||
|
|
327512da51 | ||
|
|
19ba065ca9 | ||
|
|
73a201b5cc | ||
|
|
699b9b02f3 | ||
|
|
1d1059d40e | ||
|
|
9358fa933c | ||
|
|
51bbc2f7f9 | ||
|
|
7dcc17d396 | ||
|
|
f4f4a9ab68 | ||
|
|
3af521ba42 | ||
|
|
a89ef3aeec | ||
|
|
cca18a6bc6 | ||
|
|
3e5672be46 | ||
|
|
4b2dabb3b7 | ||
|
|
508d36551f | ||
|
|
f192ca0abe | ||
|
|
d223a086bd |
9
.github/actions/spell-check/expect.txt
vendored
9
.github/actions/spell-check/expect.txt
vendored
@@ -185,6 +185,7 @@ CAPTURECHANGED
|
||||
CARETBLINKING
|
||||
carlos
|
||||
Carlseibert
|
||||
caseinsensitive
|
||||
caub
|
||||
CBN
|
||||
cch
|
||||
@@ -433,6 +434,7 @@ downsampling
|
||||
downscale
|
||||
DPICHANGED
|
||||
DPIs
|
||||
dpm
|
||||
DPMS
|
||||
DPSAPI
|
||||
DQTAT
|
||||
@@ -677,6 +679,7 @@ hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
HDEVNOTIFY
|
||||
hdmi
|
||||
hdr
|
||||
HDROP
|
||||
hdwwiz
|
||||
@@ -1249,6 +1252,7 @@ NTSTATUS
|
||||
NTSYSAPI
|
||||
nullability
|
||||
NULLCURSOR
|
||||
nullid
|
||||
nullonfailure
|
||||
nullref
|
||||
numberbox
|
||||
@@ -1311,6 +1315,7 @@ PARENTRELATIVEFORADDRESSBAR
|
||||
PARENTRELATIVEFORUI
|
||||
PARENTRELATIVEPARSING
|
||||
parray
|
||||
parseable
|
||||
PARTIALCONFIRMATIONDIALOGTITLE
|
||||
PATCOPY
|
||||
PATHMUSTEXIST
|
||||
@@ -1548,6 +1553,7 @@ Removelnk
|
||||
renamable
|
||||
RENAMEONCOLLISION
|
||||
RENDERFULLCONTENT
|
||||
renumbers
|
||||
reparented
|
||||
reparenting
|
||||
reportfileaccesses
|
||||
@@ -1561,6 +1567,7 @@ RESIZETOFIT
|
||||
resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
resx
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
@@ -1957,6 +1964,7 @@ ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
UNDNAME
|
||||
unescaped
|
||||
ungroup
|
||||
UNICODETEXT
|
||||
unins
|
||||
@@ -1969,6 +1977,7 @@ unittests
|
||||
UNLEN
|
||||
UNORM
|
||||
unparsable
|
||||
unparseable
|
||||
unremapped
|
||||
Unsend
|
||||
Unsubscribes
|
||||
|
||||
@@ -221,6 +221,9 @@
|
||||
"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",
|
||||
"PowerDisplay.Lib.dll",
|
||||
"PowerDisplay.Models.dll",
|
||||
|
||||
|
||||
@@ -722,6 +722,10 @@
|
||||
<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.Lib/PowerDisplay.Lib.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
@@ -730,6 +734,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/">
|
||||
@@ -737,6 +745,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">
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// 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.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);
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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 ──────────────────────────────────────
|
||||
[TestMethod]
|
||||
[Timeout(10_000)]
|
||||
public async Task SendAsync_WithFakeServer_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));
|
||||
|
||||
var client = new CliPipeClient();
|
||||
var result = await client.SendAsync(RequestJson, ConnectTimeout, CancellationToken.None);
|
||||
|
||||
await serverTask; // ensure the server task completes cleanly
|
||||
|
||||
Assert.AreEqual(ResponseJson, result);
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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.Properties;
|
||||
|
||||
namespace PowerDisplay.Cli.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class ResourcesTests
|
||||
{
|
||||
[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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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
|
||||
{
|
||||
/// <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, or the connection timed out.
|
||||
/// </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);
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
174
src/modules/powerdisplay/PowerDisplay.Cli/Ipc/IpcDispatcher.cs
Normal file
174
src/modules/powerdisplay/PowerDisplay.Cli/Ipc/IpcDispatcher.cs
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
210
src/modules/powerdisplay/PowerDisplay.Cli/Options/CliOptions.cs
Normal file
210
src/modules/powerdisplay/PowerDisplay.Cli/Options/CliOptions.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
// 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 'powerdisplay 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 'powerdisplay 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 'powerdisplay 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 'powerdisplay 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 'powerdisplay 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 or non-integer
|
||||
// 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))
|
||||
{
|
||||
result.ErrorMessage = Resources.Error_InvalidMonitorNumber(raw);
|
||||
return Array.Empty<int>();
|
||||
}
|
||||
|
||||
if (!numbers.Contains(number))
|
||||
{
|
||||
numbers.Add(number);
|
||||
}
|
||||
}
|
||||
|
||||
return numbers.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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($"{"#",-3} {"Name",-22} {"Method",-7} {"Monitor ID"}");
|
||||
foreach (var m in result.Monitors)
|
||||
{
|
||||
var name = Truncate(m.Name, 22);
|
||||
_stdout.WriteLine($"{m.Number,-3} {name,-22} {m.Method,-7} {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",-4} {"Name",-24} {"Monitors",-9} {"Last modified"}");
|
||||
foreach (var p in result.Profiles)
|
||||
{
|
||||
var name = Truncate(p.Name, 24);
|
||||
_stdout.WriteLine($"{p.Id,-4} {name,-24} {p.MonitorCount,-9} {p.LastModified}");
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteApplyProfileResult(CliApplyProfileResult result)
|
||||
{
|
||||
// apply-profile is best-effort: print a single confirmation line. Per-setting outcomes are
|
||||
// intentionally not reported (see CliApplyProfileResult / ApplyProfileForCliAsync).
|
||||
_stdout.WriteLine(Resources.Text_AppliedProfile(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})";
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= max)
|
||||
{
|
||||
return s ?? string.Empty;
|
||||
}
|
||||
|
||||
return s[..(max - 1)] + "…";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
552
src/modules/powerdisplay/PowerDisplay.Cli/Program.cs
Normal file
552
src/modules/powerdisplay/PowerDisplay.Cli/Program.cs
Normal file
@@ -0,0 +1,552 @@
|
||||
// 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.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, 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.Length > 0 ? monitorNumbers[0] : (int?)null, 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.Length > 0 ? monitorNumbers[0] : (int?)null, 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, 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/capabilities branches.
|
||||
private static void WarnIfMonitorNumberIgnored(ICliOutput output, int? monitorNumber, string? monitorId)
|
||||
{
|
||||
if (monitorNumber.HasValue && !string.IsNullOrEmpty(monitorId))
|
||||
{
|
||||
output.WriteWarning(Resources.Warn_MonitorNumberIgnored(monitorNumber.GetValueOrDefault()));
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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_Failed => Get(nameof(Text_Failed));
|
||||
|
||||
internal static string Text_NotConnectedSkipped => Get(nameof(Text_NotConnectedSkipped));
|
||||
|
||||
internal static string Text_NoSettingsInProfile => Get(nameof(Text_NoSettingsInProfile));
|
||||
|
||||
internal static string Text_OutOfRangeSkipped => Get(nameof(Text_OutOfRangeSkipped));
|
||||
|
||||
internal static string Text_InvalidValueSkipped => Get(nameof(Text_InvalidValueSkipped));
|
||||
|
||||
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_AppliedProfile(string profile) => Format(nameof(Text_AppliedProfile), profile);
|
||||
|
||||
internal static string Warn_MonitorNumberIgnored(int number) => Format(nameof(Warn_MonitorNumberIgnored), number);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?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_Failed" xml:space="preserve">
|
||||
<value>FAILED</value>
|
||||
<comment>Text-mode marker that an apply-profile setting failed at the hardware.</comment>
|
||||
</data>
|
||||
<data name="Text_NotConnectedSkipped" xml:space="preserve">
|
||||
<value>not connected, skipped</value>
|
||||
<comment>apply-profile: a monitor named by the profile is not currently connected.</comment>
|
||||
</data>
|
||||
<data name="Text_NoSettingsInProfile" xml:space="preserve">
|
||||
<value>no settings in profile</value>
|
||||
<comment>apply-profile: the profile entry for a connected monitor had no values.</comment>
|
||||
</data>
|
||||
<data name="Text_OutOfRangeSkipped" xml:space="preserve">
|
||||
<value>(out of range, skipped)</value>
|
||||
<comment>apply-profile: a profile value was outside the valid range and was not written.</comment>
|
||||
</data>
|
||||
<data name="Text_InvalidValueSkipped" xml:space="preserve">
|
||||
<value>(not a supported value, skipped)</value>
|
||||
<comment>apply-profile: a discrete profile value was not in the monitor's advertised set and was not written.</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_AppliedProfile" xml:space="preserve">
|
||||
<value>Applied profile '{0}':</value>
|
||||
<comment>{0} = profile name.</comment>
|
||||
</data>
|
||||
<data name="Warn_MonitorNumberIgnored" xml:space="preserve">
|
||||
<value>warning: --monitor-number {0} ignored because --monitor-id was also provided</value>
|
||||
<comment>{0} = monitor number. 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 --<setting> 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 >= 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 'powerdisplay set' to assign an absolute value</value>
|
||||
<comment>'powerdisplay 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 'powerdisplay 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 'powerdisplay list' to see available monitors</value>
|
||||
<comment>'powerdisplay 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 'powerdisplay 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 'powerdisplay profiles' to see available profiles</value>
|
||||
<comment>'powerdisplay profiles' is a literal command and must not be translated.</comment>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
71
src/modules/powerdisplay/PowerDisplay.Contracts/CliError.cs
Normal file
71
src/modules/powerdisplay/PowerDisplay.Contracts/CliError.cs
Normal 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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<->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;
|
||||
}
|
||||
18
src/modules/powerdisplay/PowerDisplay.Contracts/CliSchema.cs
Normal file
18
src/modules/powerdisplay/PowerDisplay.Contracts/CliSchema.cs
Normal 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";
|
||||
}
|
||||
@@ -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>--<name></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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
26
src/modules/powerdisplay/PowerDisplay.Contracts/PipeNames.cs
Normal file
26
src/modules/powerdisplay/PowerDisplay.Contracts/PipeNames.cs
Normal 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<->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!-- 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" />
|
||||
<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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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="AdjustCommandExecutor"/> (relative up/down on continuous settings).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AdjustCommandExecutorTests
|
||||
{
|
||||
private const int DefaultStep = 5;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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="CliRequestHandlerTests"/>;
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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.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}";
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// 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 PowerDisplay.ViewModels;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the testable core of <see cref="CliRequestHandler.BuildResponseAsync"/>.
|
||||
/// Tests drive the internal static <c>BuildResponseAsync</c> directly so that no WinUI
|
||||
/// <c>DispatcherQueue</c> or real <see cref="MainViewModel"/> is required.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class CliRequestHandlerTests
|
||||
{
|
||||
// ─── 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.
|
||||
/// </summary>
|
||||
private static Task<string> Dispatch(
|
||||
CliRequestEnvelope envelope,
|
||||
IReadOnlyList<Monitor>? monitors = null,
|
||||
PowerDisplayProfiles? profiles = null,
|
||||
Func<int, CancellationToken, Task<bool>>? applyProfile = null,
|
||||
int defaultStep = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return CliRequestHandler.BuildResponseAsync(
|
||||
envelope,
|
||||
monitors ?? new[] { MakeMon() },
|
||||
NoHidden,
|
||||
Array.Empty<CustomVcpValueMapping>(),
|
||||
new NoOpManager(),
|
||||
defaultStep,
|
||||
() => profiles ?? EmptyProfiles,
|
||||
applyProfile ?? ((_, _) => Task.FromResult(true)),
|
||||
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_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_Cancelled_ReturnsTimeoutError()
|
||||
{
|
||||
// A write that overruns the CLI timeout (or Ctrl+C) cancels the token after the hardware call;
|
||||
// SetCommandExecutor surfaces it as OperationCanceledException, which must be reported as
|
||||
// TIMEOUT (exit 8) — not INTERNAL_ERROR (exit 9).
|
||||
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>() },
|
||||
},
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── apply-profile command ────────────────────────────────────────────────
|
||||
[TestMethod]
|
||||
public async Task ApplyProfile_FoundProfile_ReturnsCliApplyProfileResult()
|
||||
{
|
||||
Func<int, CancellationToken, Task<bool>> applyFn = (id, ct) => Task.FromResult(true);
|
||||
|
||||
var profiles = new PowerDisplayProfiles
|
||||
{
|
||||
Profiles = new List<PowerDisplayProfile>
|
||||
{
|
||||
new PowerDisplayProfile { Name = "Night", MonitorSettings = new List<ProfileMonitorSetting>(), Id = 1 },
|
||||
},
|
||||
};
|
||||
|
||||
var envelope = new CliRequestEnvelope
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileId = 1 },
|
||||
};
|
||||
|
||||
var json = await Dispatch(envelope, profiles: profiles, 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_ProfileNotFound_ReturnsArgumentError()
|
||||
{
|
||||
// false = profile not found
|
||||
Func<int, CancellationToken, Task<bool>> applyFn = (id, ct) => Task.FromResult(false);
|
||||
|
||||
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<bool>> applyFn = (id, ct) => Task.FromResult(false);
|
||||
|
||||
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<bool>> applyFn = (id, ct) => Task.FromResult(true);
|
||||
|
||||
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<bool>> applyFn = (id, ct) => Task.FromResult(true);
|
||||
|
||||
var profiles = new PowerDisplayProfiles
|
||||
{
|
||||
Profiles = new List<PowerDisplayProfile>
|
||||
{
|
||||
new PowerDisplayProfile { Name = "Gaming", MonitorSettings = new List<ProfileMonitorSetting>(), Id = 3 },
|
||||
},
|
||||
};
|
||||
|
||||
var envelope = new CliRequestEnvelope
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileId = 3 },
|
||||
};
|
||||
|
||||
var json = await Dispatch(envelope, profiles: profiles, 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Ipc.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class LightSwitchProfileResolverTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LightSwitchProperties_ProfileIds_RoundTripThroughJson_DefaultZero()
|
||||
{
|
||||
var props = new LightSwitchProperties();
|
||||
Assert.AreEqual(0, props.LightModeProfileId.Value);
|
||||
Assert.AreEqual(0, props.DarkModeProfileId.Value);
|
||||
|
||||
props.LightModeProfileId.Value = 3;
|
||||
props.DarkModeProfileId.Value = 5;
|
||||
|
||||
var json = JsonSerializer.Serialize(props);
|
||||
var back = JsonSerializer.Deserialize<LightSwitchProperties>(json);
|
||||
|
||||
Assert.IsNotNull(back);
|
||||
Assert.AreEqual(3, back!.LightModeProfileId.Value);
|
||||
Assert.AreEqual(5, back.DarkModeProfileId.Value);
|
||||
}
|
||||
|
||||
private static PowerDisplayProfiles ProfilesWith(params (int Id, string Name)[] items)
|
||||
{
|
||||
var p = new PowerDisplayProfiles();
|
||||
foreach (var (id, name) in items)
|
||||
{
|
||||
var prof = new PowerDisplayProfile(name, new List<ProfileMonitorSetting>
|
||||
{
|
||||
new ProfileMonitorSetting("MON1", 50, null, null, null),
|
||||
});
|
||||
prof.Id = id;
|
||||
p.Profiles.Add(prof);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Resolve_PrefersExistingId()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "A"), (4, "B"));
|
||||
Assert.AreEqual(3, PowerDisplay.Services.LightSwitchProfileResolver.Resolve(3, "stale-name", profiles));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Resolve_IdMissing_ReturnsNull_DoesNotFallBackToName()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "A"));
|
||||
Assert.IsNull(PowerDisplay.Services.LightSwitchProfileResolver.Resolve(9, "A", profiles));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Resolve_ZeroId_FallsBackToName_FirstMatch()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "Dup"), (4, "Dup"));
|
||||
Assert.AreEqual(3, PowerDisplay.Services.LightSwitchProfileResolver.Resolve(0, "Dup", profiles));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Resolve_ZeroId_NoneOrUnknownName_ReturnsNull()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "A"));
|
||||
Assert.IsNull(PowerDisplay.Services.LightSwitchProfileResolver.Resolve(0, "(None)", profiles));
|
||||
Assert.IsNull(PowerDisplay.Services.LightSwitchProfileResolver.Resolve(0, string.Empty, profiles));
|
||||
Assert.IsNull(PowerDisplay.Services.LightSwitchProfileResolver.Resolve(0, "missing", profiles));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Migrate_ResolvesNameToId_StoresBoth_AndIsIdempotent()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "Gaming"), (4, "Movie"));
|
||||
var props = new LightSwitchProperties();
|
||||
props.LightModeProfile.Value = "Gaming";
|
||||
props.DarkModeProfile.Value = "Movie";
|
||||
|
||||
Assert.IsTrue(PowerDisplay.Services.LightSwitchProfileResolver.MigrateNamesToIds(props, profiles));
|
||||
Assert.AreEqual(3, props.LightModeProfileId.Value);
|
||||
Assert.AreEqual("Gaming", props.LightModeProfile.Value);
|
||||
Assert.AreEqual(4, props.DarkModeProfileId.Value);
|
||||
|
||||
Assert.IsFalse(PowerDisplay.Services.LightSwitchProfileResolver.MigrateNamesToIds(props, profiles)); // idempotent
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Migrate_UnknownName_ClearsToNone()
|
||||
{
|
||||
var profiles = ProfilesWith((3, "Gaming"));
|
||||
var props = new LightSwitchProperties();
|
||||
props.LightModeProfile.Value = "Deleted";
|
||||
|
||||
Assert.IsTrue(PowerDisplay.Services.LightSwitchProfileResolver.MigrateNamesToIds(props, profiles));
|
||||
Assert.AreEqual(0, props.LightModeProfileId.Value);
|
||||
Assert.AreEqual(string.Empty, props.LightModeProfile.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LightSwitchSettings_Clone_PreservesProfileIds()
|
||||
{
|
||||
var settings = new LightSwitchSettings();
|
||||
settings.Properties.DarkModeProfileId.Value = 7;
|
||||
settings.Properties.LightModeProfileId.Value = 3;
|
||||
settings.Properties.DarkModeProfile.Value = "Night";
|
||||
settings.Properties.LightModeProfile.Value = "Day";
|
||||
|
||||
var clone = (LightSwitchSettings)settings.Clone();
|
||||
|
||||
Assert.AreEqual(7, clone.Properties.DarkModeProfileId.Value);
|
||||
Assert.AreEqual(3, clone.Properties.LightModeProfileId.Value);
|
||||
Assert.AreEqual("Night", clone.Properties.DarkModeProfile.Value);
|
||||
Assert.AreEqual("Day", clone.Properties.LightModeProfile.Value);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- 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.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>
|
||||
<!--
|
||||
NOTE: The test project references PowerDisplay.Contracts and PowerDisplay.Lib directly to avoid
|
||||
pulling in the full WinUI3 app dependency chain in lightweight test scenarios; the projector
|
||||
source is included via Compile link so the test assembly can access internal helpers.
|
||||
-->
|
||||
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
|
||||
<ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
|
||||
<ProjectReference Include="..\PowerDisplay.Models\PowerDisplay.Models.csproj" />
|
||||
<ProjectReference Include="..\PowerDisplay\PowerDisplay.csproj" />
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 1–5) 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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.Models;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class PowerDisplayProfilesTests
|
||||
{
|
||||
private static PowerDisplayProfile MakeProfile(string name, int id = 0)
|
||||
{
|
||||
var p = new PowerDisplayProfile(name, new List<ProfileMonitorSetting>
|
||||
{
|
||||
new ProfileMonitorSetting("MON1", 50, null, null, null),
|
||||
});
|
||||
p.Id = id;
|
||||
return p;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IdAndNextId_RoundTripThroughJson_AndDefaultToZero()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles();
|
||||
var p = MakeProfile("Gaming", id: 7);
|
||||
profiles.Profiles.Add(p);
|
||||
profiles.NextId = 8;
|
||||
|
||||
var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles);
|
||||
var back = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles);
|
||||
|
||||
Assert.IsNotNull(back);
|
||||
Assert.AreEqual(8, back!.NextId);
|
||||
Assert.AreEqual(7, back.Profiles[0].Id);
|
||||
Assert.AreEqual(0, new PowerDisplayProfile().Id);
|
||||
Assert.AreEqual(0, new PowerDisplayProfiles().NextId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetById_ReturnsMatch_OrNullForZeroAndMissing()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles();
|
||||
var a = MakeProfile("A", id: 1);
|
||||
var b = MakeProfile("B", id: 2);
|
||||
profiles.Profiles.Add(a);
|
||||
profiles.Profiles.Add(b);
|
||||
|
||||
Assert.AreSame(b, profiles.GetById(2));
|
||||
Assert.IsNull(profiles.GetById(0));
|
||||
Assert.IsNull(profiles.GetById(99));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveProfileById_RemovesWhenPresent()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles();
|
||||
profiles.Profiles.Add(MakeProfile("A", id: 1));
|
||||
profiles.Profiles.Add(MakeProfile("B", id: 2));
|
||||
|
||||
Assert.IsTrue(profiles.RemoveProfile(2));
|
||||
Assert.AreEqual(1, profiles.Profiles.Count);
|
||||
Assert.IsFalse(profiles.RemoveProfile(2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetProfile_AssignsIncreasingIds_AndAllowsDuplicateNames()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles { NextId = 1 };
|
||||
var a = MakeProfile("Same");
|
||||
var b = MakeProfile("Same");
|
||||
|
||||
profiles.SetProfile(a);
|
||||
profiles.SetProfile(b);
|
||||
|
||||
Assert.AreEqual(1, a.Id);
|
||||
Assert.AreEqual(2, b.Id);
|
||||
Assert.AreEqual(3, profiles.NextId);
|
||||
Assert.AreEqual(2, profiles.Profiles.Count); // both kept: duplicate names allowed
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetProfile_WithExplicitId_ReplacesSameIdAndHealsNextId()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles { NextId = 1 };
|
||||
var original = MakeProfile("A", id: 5);
|
||||
profiles.SetProfile(original); // explicit id 5
|
||||
|
||||
var replacement = MakeProfile("A-edited", id: 5);
|
||||
profiles.SetProfile(replacement);
|
||||
|
||||
Assert.AreEqual(1, profiles.Profiles.Count);
|
||||
Assert.AreSame(replacement, profiles.GetById(5));
|
||||
Assert.IsTrue(profiles.NextId > 5); // healed past the explicit id
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EnsureIds_BackfillsInOrder_SetsNextId_AndIsIdempotent()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles(); // NextId defaults to 0 (legacy file)
|
||||
profiles.Profiles.Add(MakeProfile("A")); // Id 0
|
||||
profiles.Profiles.Add(MakeProfile("B")); // Id 0
|
||||
|
||||
Assert.IsTrue(profiles.EnsureIds());
|
||||
Assert.AreEqual(1, profiles.Profiles[0].Id);
|
||||
Assert.AreEqual(2, profiles.Profiles[1].Id);
|
||||
Assert.AreEqual(3, profiles.NextId);
|
||||
|
||||
Assert.IsFalse(profiles.EnsureIds()); // second run: no change
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EnsureIds_SelfHealsNextId_AndPreservesExistingIds()
|
||||
{
|
||||
var profiles = new PowerDisplayProfiles { NextId = 2 }; // corrupt: <= existing max
|
||||
profiles.Profiles.Add(MakeProfile("A", id: 5));
|
||||
profiles.Profiles.Add(MakeProfile("B")); // Id 0
|
||||
|
||||
Assert.IsTrue(profiles.EnsureIds());
|
||||
Assert.AreEqual(5, profiles.Profiles[0].Id); // preserved
|
||||
Assert.AreEqual(6, profiles.Profiles[1].Id); // assigned above max
|
||||
Assert.AreEqual(7, profiles.NextId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DisplayName_CombinesNameAndId()
|
||||
{
|
||||
var p = MakeProfile("Gaming", id: 4);
|
||||
Assert.AreEqual("Gaming (#4)", p.DisplayName);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,8 @@ namespace PowerDisplay.Common.Drivers.WMI
|
||||
GdiDeviceName = displayInfo.GdiDeviceName ?? string.Empty,
|
||||
};
|
||||
|
||||
monitor.ReadValues |= MonitorReadFlags.Brightness;
|
||||
|
||||
monitors.Add(monitor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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;
|
||||
|
||||
namespace PowerDisplay.Common.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The hardware-write slice of <see cref="MonitorManager"/> the CLI set/apply-profile commands
|
||||
/// depend on. Exists purely so those commands can be unit-tested against a fake without real
|
||||
/// hardware. Discovery and compatibility-mode toggling stay on the concrete <see cref="MonitorManager"/>.
|
||||
/// </summary>
|
||||
public interface IMonitorManager
|
||||
{
|
||||
Task<MonitorOperationResult> SetBrightnessAsync(string monitorId, int brightness, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetContrastAsync(string monitorId, int contrast, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetVolumeAsync(string monitorId, int volume, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetColorTemperatureAsync(string monitorId, int colorTemperature, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetInputSourceAsync(string monitorId, int inputSource, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetPowerStateAsync(string monitorId, int powerState, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -254,6 +254,13 @@ namespace PowerDisplay.Common.Models
|
||||
/// </summary>
|
||||
public MonitorCapabilities Capabilities { get; set; } = MonitorCapabilities.None;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of current values successfully read from the device during
|
||||
/// discovery. Callers (e.g. the CLI) use this to avoid reporting a default/stale value
|
||||
/// as the live "before" value. The GUI ignores this.
|
||||
/// </summary>
|
||||
public MonitorReadFlags ReadValues { get; set; } = MonitorReadFlags.None;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets raw DDC/CI capabilities string (MCCS format)
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
{
|
||||
[Flags]
|
||||
public enum MonitorReadFlags
|
||||
{
|
||||
None = 0,
|
||||
Brightness = 1 << 0,
|
||||
Contrast = 1 << 1,
|
||||
Volume = 1 << 2,
|
||||
ColorTemperature = 1 << 3,
|
||||
InputSource = 1 << 4,
|
||||
PowerState = 1 << 5,
|
||||
Orientation = 1 << 6,
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,19 @@ using PowerDisplay.Common.Drivers.DDC;
|
||||
using PowerDisplay.Common.Drivers.WMI;
|
||||
using PowerDisplay.Common.Interfaces;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Helpers
|
||||
namespace PowerDisplay.Common.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor manager for unified control of all monitors
|
||||
/// No interface abstraction - KISS principle (only one implementation needed)
|
||||
/// Monitor manager for unified control of all monitors. Implements <see cref="IMonitorManager"/>
|
||||
/// so consumers (e.g. the headless CLI) can depend on the abstraction and be unit-tested against a fake.
|
||||
/// </summary>
|
||||
public partial class MonitorManager : IDisposable
|
||||
// 'partial' is required by the CsWinRT source generator (CsWinRT1028) for AOT/trimming
|
||||
// compatibility because the type crosses the WinRT ABI; do not remove it.
|
||||
public partial class MonitorManager : IDisposable, IMonitorManager
|
||||
{
|
||||
private readonly List<Monitor> _monitors = new();
|
||||
private readonly Dictionary<string, Monitor> _monitorLookup = new(MonitorIdComparer.Instance);
|
||||
@@ -75,9 +76,9 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the max-compatibility-mode flag onto the DDC/CI controller. Called by
|
||||
/// <see cref="ViewModels.MainViewModel"/> before each discovery so the value is
|
||||
/// current. No-op if the DDC controller failed to initialize.
|
||||
/// Pushes the max-compatibility-mode flag onto the DDC/CI controller. Callers (the GUI's
|
||||
/// MainViewModel and the headless CLI) invoke this before discovery so the value is current.
|
||||
/// No-op if the DDC controller failed to initialize.
|
||||
/// </summary>
|
||||
public void SetMaxCompatibilityMode(bool enabled)
|
||||
{
|
||||
@@ -114,6 +115,12 @@ namespace PowerDisplay.Helpers
|
||||
_monitorLookup[monitor.Id] = monitor;
|
||||
}
|
||||
|
||||
// Controllers leave Orientation at its default (0) during discovery; query the
|
||||
// live rotation here so the very first read reflects the panel's real orientation
|
||||
// (the CLI relies on this for `get`/`set --orientation` round-tripping, and the GUI
|
||||
// shows the correct value on initial load).
|
||||
RefreshAllOrientations();
|
||||
|
||||
return _monitors.AsReadOnly();
|
||||
}
|
||||
finally
|
||||
@@ -250,7 +257,14 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
brightness,
|
||||
(ctrl, mon, val, ct) => ctrl.SetBrightnessAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentBrightness = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
// A successful write makes the value authoritatively known: set the read flag so
|
||||
// consumers (e.g. the CLI before/after display, relative up/down) can tell a real
|
||||
// value apart from the never-read default even if discovery's read had failed.
|
||||
mon.CurrentBrightness = val;
|
||||
mon.ReadValues |= MonitorReadFlags.Brightness;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -261,7 +275,11 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
contrast,
|
||||
(ctrl, mon, val, ct) => ctrl.SetContrastAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentContrast = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
mon.CurrentContrast = val;
|
||||
mon.ReadValues |= MonitorReadFlags.Contrast;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -272,7 +290,11 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
volume,
|
||||
(ctrl, mon, val, ct) => ctrl.SetVolumeAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentVolume = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
mon.CurrentVolume = val;
|
||||
mon.ReadValues |= MonitorReadFlags.Volume;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -283,7 +305,11 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
colorTemperature,
|
||||
(ctrl, mon, val, ct) => ctrl.SetColorTemperatureAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentColorTemperature = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
mon.CurrentColorTemperature = val;
|
||||
mon.ReadValues |= MonitorReadFlags.ColorTemperature;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -294,7 +320,11 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
inputSource,
|
||||
(ctrl, mon, val, ct) => ctrl.SetInputSourceAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentInputSource = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
mon.CurrentInputSource = val;
|
||||
mon.ReadValues |= MonitorReadFlags.InputSource;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -306,7 +336,11 @@ namespace PowerDisplay.Helpers
|
||||
monitorId,
|
||||
powerState,
|
||||
(ctrl, mon, val, ct) => ctrl.SetPowerStateAsync(mon, val, ct),
|
||||
(mon, val) => mon.CurrentPowerState = val,
|
||||
(mon, val) =>
|
||||
{
|
||||
mon.CurrentPowerState = val;
|
||||
mon.ReadValues |= MonitorReadFlags.PowerState;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -361,9 +395,13 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
|
||||
var currentOrientation = _rotationService.GetCurrentOrientation(monitor.GdiDeviceName);
|
||||
if (currentOrientation >= 0 && currentOrientation != monitor.Orientation)
|
||||
if (currentOrientation >= 0)
|
||||
{
|
||||
// Assigning an unchanged value is a no-op (the setter guards on equality), but the
|
||||
// read flag must be set whenever the query succeeds so consumers can tell a real
|
||||
// "0°/landscape" reading apart from the never-read default.
|
||||
monitor.Orientation = currentOrientation;
|
||||
monitor.ReadValues |= MonitorReadFlags.Orientation;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ namespace PowerDisplay.Models
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("monitorSettings")]
|
||||
public List<ProfileMonitorSetting> MonitorSettings { get; set; }
|
||||
|
||||
@@ -56,5 +59,12 @@ namespace PowerDisplay.Models
|
||||
{
|
||||
LastModified = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable label that disambiguates duplicate names, e.g. "Gaming (#4)".
|
||||
/// Not serialized; UI display only.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DisplayName => $"{Name} (#{Id})";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ namespace PowerDisplay.Models
|
||||
[JsonPropertyName("profiles")]
|
||||
public List<PowerDisplayProfile> Profiles { get; set; }
|
||||
|
||||
[JsonPropertyName("nextId")]
|
||||
public int NextId { get; set; }
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
@@ -37,7 +40,17 @@ namespace PowerDisplay.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile
|
||||
/// Gets the profile by its stable id, or null when id is not positive or no profile has it.
|
||||
/// </summary>
|
||||
public PowerDisplayProfile? GetById(int id)
|
||||
{
|
||||
return id <= 0 ? null : Profiles.FirstOrDefault(p => p.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile, keyed by its stable id. When the incoming profile has no id
|
||||
/// (Id == 0) a new one is assigned from the monotonic NextId counter. Names are not required
|
||||
/// to be unique.
|
||||
/// </summary>
|
||||
public void SetProfile(PowerDisplayProfile profile)
|
||||
{
|
||||
@@ -46,10 +59,27 @@ namespace PowerDisplay.Models
|
||||
throw new ArgumentException("Profile is invalid");
|
||||
}
|
||||
|
||||
var existing = GetProfile(profile.Name);
|
||||
if (existing != null)
|
||||
if (profile.Id == 0)
|
||||
{
|
||||
Profiles.Remove(existing);
|
||||
if (NextId < 1)
|
||||
{
|
||||
NextId = 1;
|
||||
}
|
||||
|
||||
profile.Id = NextId++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = GetById(profile.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
Profiles.Remove(existing);
|
||||
}
|
||||
|
||||
if (NextId <= profile.Id)
|
||||
{
|
||||
NextId = profile.Id + 1;
|
||||
}
|
||||
}
|
||||
|
||||
profile.Touch();
|
||||
@@ -74,23 +104,66 @@ namespace PowerDisplay.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a profile name is valid and available
|
||||
/// Removes a profile by its stable id.
|
||||
/// </summary>
|
||||
public bool IsNameAvailable(string name, string? excludeName = null)
|
||||
public bool RemoveProfile(int id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
var profile = GetById(id);
|
||||
if (profile != null)
|
||||
{
|
||||
return false;
|
||||
Profiles.Remove(profile);
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if name is already used (excluding the profile being renamed)
|
||||
var existing = GetProfile(name);
|
||||
if (existing != null && (excludeName == null || !existing.Name.Equals(excludeName, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot upgrade: assigns a stable id to every profile still missing one (Id == 0), in
|
||||
/// list order, and advances NextId past the highest id in use (self-healing a corrupt or
|
||||
/// legacy counter). Returns true when anything changed. Idempotent on subsequent calls.
|
||||
/// </summary>
|
||||
public bool EnsureIds()
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
var maxId = 0;
|
||||
foreach (var p in Profiles)
|
||||
{
|
||||
return false;
|
||||
if (p is not null && p.Id > maxId)
|
||||
{
|
||||
maxId = p.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
var next = NextId;
|
||||
if (next < 1)
|
||||
{
|
||||
next = 1;
|
||||
}
|
||||
|
||||
if (next <= maxId)
|
||||
{
|
||||
next = maxId + 1;
|
||||
}
|
||||
|
||||
foreach (var p in Profiles)
|
||||
{
|
||||
if (p is not null && p.Id == 0)
|
||||
{
|
||||
p.Id = next++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (NextId != next)
|
||||
{
|
||||
NextId = next;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,24 @@ namespace PowerDisplay.Models
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile by its stable id and persists to disk atomically.
|
||||
/// </summary>
|
||||
public static bool RemoveProfileById(int id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var profiles = LoadProfilesCore();
|
||||
bool removed = profiles.RemoveProfile(id);
|
||||
if (removed)
|
||||
{
|
||||
SaveProfilesCore(profiles);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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 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 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 step = req.Step ?? defaultStep;
|
||||
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);
|
||||
|
||||
// A blocking write that overran the CLI timeout (or Ctrl+C) cancels the token but cannot be
|
||||
// interrupted mid-call; surface it as TIMEOUT rather than reporting a false success.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.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="loadProfiles">Lazy profile loader, invoked only by the <c>profiles</c> command.</param>
|
||||
/// <param name="applyProfileAsync">Applies a profile by id; <c>false</c> result means "not found".</param>
|
||||
public CliCommandContext(
|
||||
CliRequestEnvelope envelope,
|
||||
IReadOnlyList<Monitor> snapshot,
|
||||
IReadOnlySet<string> hiddenIds,
|
||||
IReadOnlyList<CustomVcpValueMapping> customMappings,
|
||||
IMonitorManager manager,
|
||||
int defaultStep,
|
||||
Func<PowerDisplayProfiles> loadProfiles,
|
||||
Func<int, CancellationToken, Task<bool>> applyProfileAsync)
|
||||
{
|
||||
Envelope = envelope;
|
||||
Snapshot = snapshot;
|
||||
HiddenIds = hiddenIds;
|
||||
CustomMappings = customMappings;
|
||||
Manager = manager;
|
||||
DefaultStep = defaultStep;
|
||||
LoadProfiles = loadProfiles;
|
||||
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 profile loader, invoked only by the <c>profiles</c> command.</summary>
|
||||
public Func<PowerDisplayProfiles> LoadProfiles { get; }
|
||||
|
||||
/// <summary>Applies a profile by id (best-effort); returns <c>false</c> when the profile is not found.</summary>
|
||||
public Func<int, CancellationToken, Task<bool>> ApplyProfileAsync { get; }
|
||||
}
|
||||
169
src/modules/powerdisplay/PowerDisplay/Ipc/CliCommandHandlers.cs
Normal file
169
src/modules/powerdisplay/PowerDisplay/Ipc/CliCommandHandlers.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// 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).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 Task<string> ExecuteAsync(CliCommandContext context, CancellationToken ct)
|
||||
{
|
||||
var result = ProfileDtoProjector.BuildProfileListResult(context.LoadProfiles());
|
||||
return Task.FromResult(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 applied = await context.ApplyProfileAsync(profileId, ct).ConfigureAwait(false);
|
||||
if (!applied)
|
||||
{
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeCodedError(
|
||||
CliCommandNames.ApplyProfile,
|
||||
CliErrorCodes.ArgumentError,
|
||||
CliMessageIds.ProfileNotFound,
|
||||
value: profileId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
var name = context.LoadProfiles().GetById(profileId)?.Name ?? string.Empty;
|
||||
var applyResult = new CliApplyProfileResult { ProfileId = profileId, Profile = name };
|
||||
return CliResponse.Serialize(applyResult, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/modules/powerdisplay/PowerDisplay/Ipc/CliErrorFactory.cs
Normal file
48
src/modules/powerdisplay/PowerDisplay/Ipc/CliErrorFactory.cs
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
326
src/modules/powerdisplay/PowerDisplay/Ipc/CliPipeServer.cs
Normal file
326
src/modules/powerdisplay/PowerDisplay/Ipc/CliPipeServer.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
// 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 ManagedCommon;
|
||||
using PowerDisplay.Contracts;
|
||||
|
||||
namespace PowerDisplay.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// App-side named-pipe server that accepts CLI connections and dispatches each request through
|
||||
/// <see cref="CliRequestHandler"/>.
|
||||
/// <para>
|
||||
/// <b>Protocol:</b> One connection = one request/response exchange. The server reads one
|
||||
/// <c>'\n'</c>-delimited JSON line, calls <see cref="CliRequestHandler.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<T>.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
|
||||
/// replacement instance is created before the just-served one is disposed, so up to two instances
|
||||
/// exist briefly; <see cref="NamedPipeServerStream.MaxAllowedServerInstances"/> is passed to allow
|
||||
/// that 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="CliRequestHandler.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 CliRequestHandler _handler;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the server with the request handler that will be called for each connection.
|
||||
/// </summary>
|
||||
/// <param name="handler">The handler that processes each request. Must not be null.</param>
|
||||
public CliPipeServer(CliRequestHandler handler)
|
||||
=> _handler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
|
||||
/// <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 stood up 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, take the name under its own ACL,
|
||||
// and thereafter both deny the real server (its FirstPipeInstance create would fail forever)
|
||||
// and intercept/spoof CLI traffic (the client connects by name only, it does not verify the
|
||||
// server).
|
||||
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`. Stand up the replacement instance now, while
|
||||
// the connected one still holds the name, so ownership is continuous; then serve
|
||||
// and dispose the connected instance.
|
||||
var connected = listener;
|
||||
listener = CreateServerStream(pipeName, security, firstInstance: false);
|
||||
|
||||
try
|
||||
{
|
||||
await ServeOneAsync(connected, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connected.Dispose();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception 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(500, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener?.Dispose();
|
||||
}
|
||||
|
||||
Logger.LogInfo("[PowerDisplay CLI IPC] Server stopped.");
|
||||
}
|
||||
|
||||
/// <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 _handler.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/modules/powerdisplay/PowerDisplay/Ipc/CliRequestHandler.cs
Normal file
238
src/modules/powerdisplay/PowerDisplay/Ipc/CliRequestHandler.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Contracts;
|
||||
using PowerDisplay.Models;
|
||||
using PowerDisplay.ViewModels;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// App-side IPC dispatcher. Called from the named-pipe server (Task 3.1) on a background thread.
|
||||
/// <para>
|
||||
/// <b>Threading:</b> ViewModel/MonitorManager access is <em>initiated</em> on the UI thread via the
|
||||
/// injected <see cref="DispatcherQueue"/> using a <see cref="TaskCompletionSource{T}"/> pattern
|
||||
/// (see <c>RunOnUiThreadAsync</c>): the synchronous VM snapshot reads and the dispatch of each
|
||||
/// hardware write run on the UI thread. The work is awaited with <c>ConfigureAwait(false)</c>, so
|
||||
/// continuations after an incomplete hardware-write await resume on a thread-pool thread — the
|
||||
/// MonitorManager controllers are already thread-affinity-free, matching the pattern established by
|
||||
/// <c>ApplyLightSwitchProfile</c> in <see cref="MainViewModel"/>. Only serialization of the
|
||||
/// resulting DTO happens on the background thread.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error contract:</b> <see cref="HandleAsync"/> never throws. Cancellation (Ctrl+C / overrun
|
||||
/// of the CLI timeout) is reported as <see cref="CliErrorCodes.Timeout"/> / exit 8; any other
|
||||
/// unexpected exception is reported as <see cref="CliErrorCodes.InternalError"/> / exit 9.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CliRequestHandler
|
||||
{
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the handler with the live <see cref="MainViewModel"/> and the WinUI dispatcher
|
||||
/// that owns the ViewModel's data (Monitors, MonitorManager, settings utils).
|
||||
/// </summary>
|
||||
/// <param name="vm">The app's main view-model. Must not be null.</param>
|
||||
/// <param name="dispatcherQueue">
|
||||
/// The UI-thread dispatcher that owns <paramref name="vm"/>. Must not be null.
|
||||
/// </param>
|
||||
public CliRequestHandler(MainViewModel vm, DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||||
_dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue));
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON <paramref name="requestJson"/>, routes it to the matching
|
||||
/// <see cref="ICliCommandHandler"/> on the UI thread, and returns the serialized response JSON.
|
||||
/// </summary>
|
||||
/// <param name="requestJson">One-line JSON request from the pipe client.</param>
|
||||
/// <param name="ct">Cancellation token (Ctrl-C / server timeout).</param>
|
||||
/// <returns>
|
||||
/// A one-line JSON string. Always a valid response — never throws. Cancellation maps to
|
||||
/// <see cref="CliErrorCodes.Timeout"/>; any other unexpected exception maps to
|
||||
/// <see cref="CliErrorCodes.InternalError"/>.
|
||||
/// </returns>
|
||||
public async Task<string> HandleAsync(string requestJson, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await HandleCoreAsync(requestJson, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancellation while marshalling onto the UI thread (before the command ran). Mirror the
|
||||
// command-execution timeout contract: report TIMEOUT (exit 8), not INTERNAL_ERROR.
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeError("unknown", CliErrorCodes.Timeout, "request timed out or was cancelled"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[CliRequestHandler] Unexpected exception in HandleAsync: {ex.GetType().Name}: {ex.Message}");
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal testable core ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Testable dispatch core. Takes pre-fetched VM state instead of accessing the ViewModel
|
||||
/// directly, so unit tests can drive it without a WinUI DispatcherQueue.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The parsed request envelope.</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="manager">The live <see cref="IMonitorManager"/> for hardware writes.</param>
|
||||
/// <param name="loadProfiles">
|
||||
/// Lazy profile loader, invoked only by the <c>profiles</c> command so the read commands do not
|
||||
/// pay for synchronous disk I/O they never use. Maps to <c>ProfileService.LoadProfiles</c>.
|
||||
/// </param>
|
||||
/// <param name="applyProfileAsync">
|
||||
/// Delegate that applies a profile by id (best-effort) and returns <c>true</c> when the
|
||||
/// profile was found and applied, or <c>false</c> when it does not exist. Receives the profile
|
||||
/// id and a <see cref="CancellationToken"/>. Maps to
|
||||
/// <c>MainViewModel.ApplyProfileForCliAsync</c> in production.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>One-line JSON response string.</returns>
|
||||
internal static async Task<string> BuildResponseAsync(
|
||||
CliRequestEnvelope envelope,
|
||||
IReadOnlyList<Monitor> snapshot,
|
||||
IReadOnlySet<string> hiddenIds,
|
||||
IReadOnlyList<CustomVcpValueMapping> customMappings,
|
||||
IMonitorManager manager,
|
||||
int defaultStep,
|
||||
Func<PowerDisplayProfiles> loadProfiles,
|
||||
Func<int, CancellationToken, Task<bool>> applyProfileAsync,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CliCommandHandlers.TryGet(envelope.Command, out var handler))
|
||||
{
|
||||
var context = new CliCommandContext(
|
||||
envelope,
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
customMappings,
|
||||
manager,
|
||||
defaultStep,
|
||||
loadProfiles,
|
||||
applyProfileAsync);
|
||||
|
||||
return await handler.ExecuteAsync(context, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A command name the app does not recognize is a bad argument (e.g. a newer CLI talking
|
||||
// to an older app), not an internal app fault — map it to ARGUMENT_ERROR (exit 7), like
|
||||
// the apply-profile not-found path, not INTERNAL_ERROR (exit 9).
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeCodedError(envelope.Command, CliErrorCodes.ArgumentError, CliMessageIds.UnknownCommand, value: envelope.Command));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// A blocking hardware write (set / apply-profile) overran the CLI timeout or was cancelled
|
||||
// (Ctrl+C). The partial write cannot be rolled back, so report TIMEOUT (exit 8) rather
|
||||
// than a false success — this honours the contract documented in SetCommandExecutor.
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeError(envelope.Command, CliErrorCodes.Timeout, "operation timed out or was cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||
private async Task<string> HandleCoreAsync(string requestJson, CancellationToken ct)
|
||||
{
|
||||
CliRequestEnvelope? envelope = null;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize(requestJson, ContractsJsonContext.Default.CliRequestEnvelope);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Logger.LogWarning($"[CliRequestHandler] Failed to parse request JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
if (envelope is null || string.IsNullOrEmpty(envelope.Command))
|
||||
{
|
||||
return CliResponse.SerializeError(
|
||||
CliResponse.MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: "could not parse request envelope"));
|
||||
}
|
||||
|
||||
// Marshal all ViewModel/MonitorManager access onto the UI thread.
|
||||
// Serialization of the resulting string happens back on this background thread.
|
||||
return await RunOnUiThreadAsync(async () =>
|
||||
{
|
||||
// Snapshot VM state on the UI thread — these reads touch _settingsUtils and
|
||||
// _monitors which are UI-thread-owned. Profiles are loaded lazily (only the
|
||||
// 'profiles' command pays for the disk read).
|
||||
var snapshot = _vm.SnapshotMonitors();
|
||||
var hiddenIds = _vm.GetHiddenMonitorIds();
|
||||
var customMappings = _vm.CustomVcpMappings;
|
||||
var manager = _vm.MonitorManager;
|
||||
|
||||
return await BuildResponseAsync(
|
||||
envelope,
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
customMappings,
|
||||
manager,
|
||||
_vm.MouseWheelIncrement,
|
||||
ProfileService.LoadProfiles,
|
||||
(id, token) => _vm.ApplyProfileForCliAsync(id, token),
|
||||
ct).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals async work onto the UI dispatcher thread and returns the result on the calling
|
||||
/// background thread. Uses the <see cref="TaskCompletionSource{T}"/> + <c>TryEnqueue</c>
|
||||
/// pattern established by <c>ApplyLightSwitchProfile</c> in <see cref="MainViewModel"/>.
|
||||
/// </summary>
|
||||
private Task<T> RunOnUiThreadAsync<T>(Func<Task<T>> work)
|
||||
{
|
||||
System.Diagnostics.Debug.Assert(!_dispatcherQueue.HasThreadAccess, "HandleAsync must be called from a background thread, not the UI thread");
|
||||
|
||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var enqueued = _dispatcherQueue.TryEnqueue(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await work().ConfigureAwait(false);
|
||||
tcs.TrySetResult(result);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
tcs.TrySetCanceled(ex.CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (!enqueued)
|
||||
{
|
||||
Logger.LogError("[CliRequestHandler] Failed to enqueue work to UI thread — dispatcher may be shutting down");
|
||||
tcs.TrySetException(new InvalidOperationException("UI dispatcher could not accept work (app may be shutting down)"));
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
68
src/modules/powerdisplay/PowerDisplay/Ipc/CliResponse.cs
Normal file
68
src/modules/powerdisplay/PowerDisplay/Ipc/CliResponse.cs
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
15
src/modules/powerdisplay/PowerDisplay/Ipc/CliSettingKind.cs
Normal file
15
src/modules/powerdisplay/PowerDisplay/Ipc/CliSettingKind.cs
Normal 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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
183
src/modules/powerdisplay/PowerDisplay/Ipc/CliVcpSetting.cs
Normal file
183
src/modules/powerdisplay/PowerDisplay/Ipc/CliVcpSetting.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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)
|
||||
{
|
||||
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);
|
||||
|
||||
// A blocking write that overran the CLI timeout (or Ctrl+C) cancels the token but cannot be
|
||||
// interrupted mid-call; surface it as TIMEOUT rather than reporting a false success.
|
||||
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) : null,
|
||||
AfterDisplay = FormatDisplay(value.Value),
|
||||
}, 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;
|
||||
}
|
||||
@@ -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 + "%";
|
||||
}
|
||||
138
src/modules/powerdisplay/PowerDisplay/Ipc/DiscreteVcpSetting.cs
Normal file
138
src/modules/powerdisplay/PowerDisplay/Ipc/DiscreteVcpSetting.cs
Normal 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 <name></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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user