mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 16:39:14 +02:00
Compare commits
29 Commits
main
...
yuleng/pd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
10
.github/actions/spell-check/expect.txt
vendored
10
.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
|
||||
@@ -502,6 +504,7 @@ EREOF
|
||||
EResize
|
||||
ERRORIMAGE
|
||||
ERRORTITLE
|
||||
esac
|
||||
esrp
|
||||
etd
|
||||
ETDT
|
||||
@@ -677,6 +680,7 @@ hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
HDEVNOTIFY
|
||||
hdmi
|
||||
hdr
|
||||
HDROP
|
||||
hdwwiz
|
||||
@@ -1249,6 +1253,7 @@ NTSTATUS
|
||||
NTSYSAPI
|
||||
nullability
|
||||
NULLCURSOR
|
||||
nullid
|
||||
nullonfailure
|
||||
nullref
|
||||
numberbox
|
||||
@@ -1311,6 +1316,7 @@ PARENTRELATIVEFORADDRESSBAR
|
||||
PARENTRELATIVEFORUI
|
||||
PARENTRELATIVEPARSING
|
||||
parray
|
||||
parseable
|
||||
PARTIALCONFIRMATIONDIALOGTITLE
|
||||
PATCOPY
|
||||
PATHMUSTEXIST
|
||||
@@ -1548,6 +1554,7 @@ Removelnk
|
||||
renamable
|
||||
RENAMEONCOLLISION
|
||||
RENDERFULLCONTENT
|
||||
renumbers
|
||||
reparented
|
||||
reparenting
|
||||
reportfileaccesses
|
||||
@@ -1561,6 +1568,7 @@ RESIZETOFIT
|
||||
resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
resx
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
@@ -1957,6 +1965,7 @@ ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
UNDNAME
|
||||
unescaped
|
||||
ungroup
|
||||
UNICODETEXT
|
||||
unins
|
||||
@@ -1969,6 +1978,7 @@ unittests
|
||||
UNLEN
|
||||
UNORM
|
||||
unparsable
|
||||
unparseable
|
||||
unremapped
|
||||
Unsend
|
||||
Unsubscribes
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -381,3 +381,4 @@ deps/vcpkg/
|
||||
|
||||
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -69,10 +69,24 @@ Reference implementations:
|
||||
|
||||
### Exit Codes
|
||||
|
||||
Use `0` for success and a non-zero code for failure. A minimal CLI can use:
|
||||
|
||||
- `0`: Success
|
||||
- `1`: General error (parsing, validation, runtime)
|
||||
- `2`: Invalid arguments (optional)
|
||||
|
||||
Modules **MAY** define a richer, module-specific exit-code scheme when scripts benefit from
|
||||
distinguishing failure kinds (e.g. not-found vs. out-of-range vs. hardware failure). When you do:
|
||||
|
||||
- Keep the code→meaning mapping in one place (a single source of truth) so an error's code and its
|
||||
exit code cannot drift.
|
||||
- **Document it in the module's own docs** — do not assume the minimal `1`/`2` meanings above carry
|
||||
over. In a richer scheme `2` may mean something else (e.g. "out of range"), so a consumer must read
|
||||
the module's table, not this baseline.
|
||||
|
||||
For a worked example see the PowerDisplay CLI ([`modules/powerdisplay/cli.md`](modules/powerdisplay/cli.md)),
|
||||
which maps ten distinct error codes to exit codes `1`–`10`.
|
||||
|
||||
### Exception Handling
|
||||
|
||||
- Always wrap `Main()` in try-catch for unhandled exceptions.
|
||||
|
||||
132
doc/devdocs/modules/powerdisplay/cli.md
Normal file
132
doc/devdocs/modules/powerdisplay/cli.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# PowerDisplay CLI
|
||||
|
||||
`PowerToys.PowerDisplay.Cli.exe` is a headless command-line front end for controlling monitor
|
||||
settings (brightness, contrast, volume, color temperature, input source, power state, orientation)
|
||||
and applying saved profiles.
|
||||
|
||||
The examples below use `powerdisplay` as shorthand — that is the name the tool uses for itself in
|
||||
its `--help` output and error hints. There is no separate `powerdisplay` shim today; invoke the
|
||||
executable by its real name (`PowerToys.PowerDisplay.Cli.exe`) or via your own alias.
|
||||
|
||||
## How it works
|
||||
|
||||
The CLI is a thin client. It does **not** talk to the hardware directly: it connects to the running
|
||||
PowerDisplay app over a per-session named pipe (`PipeNames.CliServer()`), sends one JSON request,
|
||||
and renders the one JSON response the app returns.
|
||||
|
||||
- **The PowerDisplay module must be enabled and running.** If it is not, the CLI exits with `10`
|
||||
(`PROVIDER_UNAVAILABLE`) after a short connect timeout.
|
||||
- The pipe is ACL'd to the current user's SID, so a non-elevated CLI can drive a same-user elevated
|
||||
app (and other users are denied). See `PowerDisplay/Ipc/CliPipeServer.cs`.
|
||||
- One invocation is bounded by an overall deadline (`Program.OperationTimeout`, 5s); the connect
|
||||
phase is bounded separately and shorter (`Program.ConnectTimeout`, 2s) so a not-running app fails
|
||||
fast and correctly as `PROVIDER_UNAVAILABLE` rather than `TIMEOUT`.
|
||||
|
||||
Human-readable text goes to **stdout** (success) and **stderr** (warnings/errors). Scripts should
|
||||
branch on the **process exit code** (below), which is the stable machine contract.
|
||||
|
||||
## Commands
|
||||
|
||||
Canonical names live in `PowerDisplay.Contracts/Requests/CliCommandNames.cs`.
|
||||
|
||||
| Command | Purpose | Selector |
|
||||
|---|---|---|
|
||||
| `list` | Discover attached monitors (number, id, name, transport). | none |
|
||||
| `get` | Read the current value of one or all settings. | optional (omit = all monitors) |
|
||||
| `set` | Apply exactly one setting to a monitor. | required |
|
||||
| `up` / `down` | Raise / lower one continuous setting relative to its current value. | required |
|
||||
| `capabilities` | Print the monitor's advertised VCP capabilities. | required |
|
||||
| `profiles` | List saved profiles (name, monitor count, last modified). | none |
|
||||
| `apply-profile <name>` | Apply a saved profile's per-monitor settings. | none |
|
||||
|
||||
### Selecting a monitor
|
||||
|
||||
- `-n`, `--monitor-number <n>` — 1-based index from `list`.
|
||||
- `-i`, `--monitor-id <id>` — stable id from `list`. **Wins** if both are supplied (the CLI prints a
|
||||
note that `-n` was ignored).
|
||||
|
||||
### Settings
|
||||
|
||||
Names live in `PowerDisplay.Contracts/CliSettingNames.cs`.
|
||||
|
||||
| Setting | `set` flag | Kind | Value |
|
||||
|---|---|---|---|
|
||||
| brightness | `--brightness <0-100>` | continuous | percent |
|
||||
| contrast | `--contrast <0-100>` | continuous | percent |
|
||||
| volume | `--volume <0-100>` | continuous | percent |
|
||||
| color-temperature | `--color-temperature <0xNN>` | discrete | hex VCP value |
|
||||
| input-source | `--input-source <0xNN>` | discrete | hex VCP value |
|
||||
| power-state | `--power-state <0xNN>` | discrete | hex VCP value |
|
||||
| orientation | `--orientation <0\|90\|180\|270>` | GDI | degrees |
|
||||
|
||||
- Discrete values are **hex only** (e.g. `0x05`); friendly names are not accepted because the generic
|
||||
VCP name table can disagree with a specific panel. Run `capabilities --setting <name>` to list the
|
||||
values a monitor actually advertises.
|
||||
- `set` requires **exactly one** setting flag.
|
||||
- `up`/`down` accept one of `--brightness` / `--contrast` / `--volume` as a **no-value presence flag**,
|
||||
plus optional `--step <n>` (defaults to the PowerDisplay `mouse_wheel_increment` setting).
|
||||
- Applying a `--power-state` that blanks the panel requires `--confirm-power-off`.
|
||||
|
||||
### Global options
|
||||
|
||||
- `--quiet` — suppress warning messages on stderr.
|
||||
|
||||
## Exit codes
|
||||
|
||||
Single source of truth: `PowerDisplay.Contracts/CliExitCodes.cs` (paired 1:1 with the `error.code`
|
||||
strings in `CliErrorCodes.cs`). **This scheme extends the baseline in
|
||||
[`../../cli-conventions.md`](../../cli-conventions.md); exit code `2` here means "out of range", not
|
||||
"invalid arguments".**
|
||||
|
||||
| Exit | `error.code` | Meaning |
|
||||
|---|---|---|
|
||||
| 0 | — | Success |
|
||||
| 1 | `MONITOR_NOT_FOUND` | The selected monitor number/id was not found. |
|
||||
| 2 | `OUT_OF_RANGE` | A continuous value was outside `[0, 100]`. |
|
||||
| 3 | `INVALID_DISCRETE_VALUE` | A discrete or orientation value was invalid, or not in the monitor's advertised set. |
|
||||
| 4 | `UNSUPPORTED_FEATURE` | The monitor does not support the requested setting. |
|
||||
| 5 | `HARDWARE_FAILURE` | The DDC/CI or GDI write failed. |
|
||||
| 6 | `SELECTOR_MISSING` | A command that needs a monitor was given none. |
|
||||
| 7 | `ARGUMENT_ERROR` | Invalid arguments (unknown setting, bad combination, parse error). |
|
||||
| 8 | `TIMEOUT` | The operation exceeded the deadline or was cancelled (Ctrl+C). |
|
||||
| 9 | `INTERNAL_ERROR` | Unexpected failure. |
|
||||
| 10 | `PROVIDER_UNAVAILABLE` | The PowerDisplay app is not running / unreachable. |
|
||||
|
||||
For `apply-profile`, the exit code is the **worst** per-setting outcome across all monitors
|
||||
(`HARDWARE_FAILURE` > `INVALID_DISCRETE_VALUE` > `OUT_OF_RANGE` > success); `unsupported` settings do
|
||||
not fail the command.
|
||||
|
||||
## Examples
|
||||
|
||||
```pwsh
|
||||
# List monitors
|
||||
powerdisplay list
|
||||
|
||||
# Read everything for monitor 1
|
||||
powerdisplay get -n 1
|
||||
|
||||
# Read just brightness for a specific monitor id
|
||||
powerdisplay get -i "\\?\DISPLAY#..." --setting brightness
|
||||
|
||||
# Set brightness to 60% on monitor 2
|
||||
powerdisplay set -n 2 --brightness 60
|
||||
|
||||
# Nudge volume down by 5
|
||||
powerdisplay down -n 1 --volume --step 5
|
||||
|
||||
# Discover the color-temperature values a monitor advertises, then set one
|
||||
powerdisplay capabilities -n 1 --setting color-temperature
|
||||
powerdisplay set -n 1 --color-temperature 0x05
|
||||
|
||||
# Power the panel off (requires explicit confirmation)
|
||||
powerdisplay set -n 1 --power-state 0x04 --confirm-power-off
|
||||
|
||||
# Apply a saved profile
|
||||
powerdisplay apply-profile "Night"
|
||||
```
|
||||
|
||||
## Related source
|
||||
|
||||
- CLI client: `src/modules/powerdisplay/PowerDisplay.Cli/`
|
||||
- Shared contracts / DTOs: `src/modules/powerdisplay/PowerDisplay.Contracts/`
|
||||
- App-side IPC (pipe server, executors, projectors): `src/modules/powerdisplay/PowerDisplay/Ipc/`
|
||||
5
doc/devdocs/tools/clean-up-tool.md
Normal file
5
doc/devdocs/tools/clean-up-tool.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
|
||||
|
||||
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
|
||||
|
||||
This tool is currently very outdated and just cleans up the registry keys of some few modules.
|
||||
@@ -10,6 +10,7 @@ Following tools are currently available:
|
||||
|
||||
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
|
||||
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
|
||||
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
|
||||
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
|
||||
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
|
||||
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.Foundation;
|
||||
using WinUIEx;
|
||||
|
||||
@@ -39,18 +37,6 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
|
||||
/// content has finished animating out. With no listener the window simply shows
|
||||
/// or hides immediately.</para>
|
||||
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
|
||||
/// may host on the same window by each calling
|
||||
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
|
||||
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
|
||||
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
|
||||
/// window is hidden only after <em>all</em> surfaces have finished animating
|
||||
/// out. To let each surface play its own distinct transition, call the
|
||||
/// parameterless <see cref="Show()"/> (so every surface uses its configured
|
||||
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
|
||||
/// overload instead broadcasts a single transition to all surfaces. Sizing the
|
||||
/// window and positioning each surface within it remain the consumer's
|
||||
/// responsibility (this window owns no layout).</para>
|
||||
/// </remarks>
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
@@ -66,9 +52,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
|
||||
private readonly nint _hwnd;
|
||||
|
||||
private bool _inputHooked;
|
||||
private bool _seenActivated;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
@@ -91,30 +74,8 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
|
||||
Activated += OnActivatedForDismiss;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
|
||||
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
|
||||
/// Defaults to <see langword="false"/>. The window is shown without
|
||||
/// activation, so the consumer must activate it for its content to receive
|
||||
/// keyboard input.
|
||||
/// </summary>
|
||||
public bool DismissOnEscape { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the window dismisses itself
|
||||
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
|
||||
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
|
||||
/// window has been activated at least once since the last <see cref="Show()"/>,
|
||||
/// so the transient deactivation that can occur during the show sequence does
|
||||
/// not dismiss it prematurely. The window is shown without activation, so the
|
||||
/// consumer must activate it for this to apply.
|
||||
/// </summary>
|
||||
public bool DismissOnFocusLost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised (without activation) when <see cref="Show()"/> makes the window
|
||||
/// visible. A content surface subscribes to this to play its in-animation,
|
||||
@@ -151,8 +112,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_seenActivated = false;
|
||||
EnsureInputHooks();
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
Showing?.Invoke(this, new ShowingEventArgs(transition));
|
||||
});
|
||||
@@ -175,41 +134,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
});
|
||||
}
|
||||
|
||||
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
{
|
||||
if (DismissOnFocusLost && _seenActivated)
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_seenActivated = true;
|
||||
}
|
||||
|
||||
private void EnsureInputHooks()
|
||||
{
|
||||
if (_inputHooked || Content is not UIElement element)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
element.KeyDown += OnContentKeyDown;
|
||||
_inputHooked = true;
|
||||
}
|
||||
|
||||
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
|
||||
@@ -32,17 +32,6 @@ namespace EnvironmentVariables
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// The WinUI TitleBar control reads the owning window's title (AppWindow.Title) during a
|
||||
// deferred layout pass. If the native window title is empty at that instant, the windowing
|
||||
// layer can fault while resolving it and terminate the process. ResourceLoader.GetString
|
||||
// returns an empty string when the resource map can't be resolved at runtime, which would
|
||||
// leave the title empty here, so fall back to a non-empty product name to keep the native
|
||||
// window title populated.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Environment Variables";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
@@ -25,15 +25,6 @@ namespace FileLocksmithUI
|
||||
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "File Locksmith";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
}
|
||||
|
||||
@@ -33,15 +33,6 @@ namespace Hosts
|
||||
var loader = new ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
|
||||
|
||||
var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Hosts File Editor";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
@@ -57,17 +57,7 @@ namespace ShortcutGuide
|
||||
return _currentApplicationIds;
|
||||
});
|
||||
|
||||
var title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Shortcut Guide";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
Title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
|
||||
#if !DEBUG
|
||||
|
||||
@@ -160,7 +160,7 @@ bool WindowBorder::Init(HINSTANCE hinstance)
|
||||
|
||||
void WindowBorder::UpdateBorderPosition() const
|
||||
{
|
||||
if (!m_trackingWindow || !m_frameDrawer || !m_window)
|
||||
if (!m_trackingWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
var line = 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,320 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Cli.Commands;
|
||||
using PowerDisplay.Cli.Ipc;
|
||||
using PowerDisplay.Cli.Output;
|
||||
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 sealed class CaptureOutput : ICliOutput, IDisposable
|
||||
{
|
||||
private readonly List<string> stdoutLines = new();
|
||||
|
||||
private readonly List<string> stderrLines = new();
|
||||
|
||||
private readonly StringWriter stdout = new();
|
||||
|
||||
private readonly StringWriter stderr = new();
|
||||
|
||||
public IReadOnlyList<string> StdoutLines => this.stdoutLines;
|
||||
|
||||
public IReadOnlyList<string> StderrLines => this.stderrLines;
|
||||
|
||||
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.ExitCode);
|
||||
|
||||
public void WriteError(CliErrorResult r) => this.stderrLines.Add("error:" + r.Error.Code + ":" + r.Error.ExitCode);
|
||||
|
||||
public void WriteWarning(string message) => this.stderrLines.Add("warn:" + message);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.stdout.Dispose();
|
||||
this.stderr.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static IpcDispatcher MakeDispatcher(string? stubResponse, CaptureOutput 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 CaptureOutput();
|
||||
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 CaptureOutput();
|
||||
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 CaptureOutput();
|
||||
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 exit-code carried through IPC ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the app returns a canned CliApplyProfileResult with
|
||||
/// ExitCode=2 (OutOfRange), the CLI dispatcher returns exit 2, NOT the old hardcoded 5
|
||||
/// (HardwareFailure). This is the regression test for the apply-profile exit-code bug.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task ApplyProfile_OutOfRange_partial_failure_exits_2()
|
||||
{
|
||||
var output = new CaptureOutput();
|
||||
var responseJson = SerializeSuccess(
|
||||
new CliApplyProfileResult
|
||||
{
|
||||
ExitCode = CliExitCodes.OutOfRange,
|
||||
Profile = "Night",
|
||||
Monitors = new List<CliProfileMonitorOutcome>
|
||||
{
|
||||
new CliProfileMonitorOutcome
|
||||
{
|
||||
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
|
||||
Connected = true,
|
||||
Changes = new List<CliProfileChange>
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 110, Status = CliProfileChange.StatusOutOfRange },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
var dispatcher = MakeDispatcher(responseJson, output);
|
||||
var exit = await dispatcher.SendApplyProfileAsync(CliRequestBuilder.BuildApplyProfile("Night"), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.OutOfRange, exit, "OutOfRange partial failure must return exit 2, not hardcoded HardwareFailure(5)");
|
||||
|
||||
// A partial-failure apply-profile result is a SUCCESS envelope (isError=false): it must route
|
||||
// through the success renderer (stdout) and never WriteError — purely on the explicit discriminator.
|
||||
Assert.AreEqual(1, output.StdoutLines.Count, "rendered via the success path");
|
||||
Assert.AreEqual(0, output.StderrLines.Count, "must not go through WriteError");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ApplyProfile_full_success_exits_0()
|
||||
{
|
||||
var output = new CaptureOutput();
|
||||
var responseJson = SerializeSuccess(
|
||||
new CliApplyProfileResult
|
||||
{
|
||||
ExitCode = CliExitCodes.Ok,
|
||||
Profile = "Work",
|
||||
Monitors = new List<CliProfileMonitorOutcome>(),
|
||||
},
|
||||
ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
var dispatcher = MakeDispatcher(responseJson, output);
|
||||
var exit = await dispatcher.SendApplyProfileAsync(CliRequestBuilder.BuildApplyProfile("Work"), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.Ok, exit);
|
||||
}
|
||||
|
||||
// ── schema-mismatch / undeserializable response → InternalError (9) ────────
|
||||
[TestMethod]
|
||||
public async Task Malformed_json_response_exits_internal_error()
|
||||
{
|
||||
var output = new CaptureOutput();
|
||||
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 CaptureOutput();
|
||||
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_ProfileName()
|
||||
{
|
||||
var envelope = CliRequestBuilder.BuildApplyProfile("Night");
|
||||
Assert.AreEqual(CliCommandNames.ApplyProfile, envelope.Command);
|
||||
Assert.AreEqual("Night", envelope.ApplyProfile!.ProfileName);
|
||||
}
|
||||
|
||||
// ── 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 CaptureOutput();
|
||||
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,164 @@
|
||||
// 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 ApplyProfileWithRealName_IsNotHelp()
|
||||
=> Assert.IsFalse(Program.HasHelpToken(Parse("apply-profile", "Night")));
|
||||
|
||||
[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 <name>` greedily binds "--version" as the profile name, so it never reaches
|
||||
// UnmatchedTokens. It must still be treated as a version request (mirrors the --help carve-out)
|
||||
// rather than dispatched as "apply a profile literally named --version".
|
||||
Assert.IsTrue(Program.IsVersionRequest(Parse("apply-profile", "--version")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ApplyProfileWithRealName_IsNotVersion()
|
||||
=> Assert.IsFalse(Program.IsVersionRequest(Parse("apply-profile", "Night")));
|
||||
|
||||
[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_DoesNotSwallowFollowingProfileName()
|
||||
{
|
||||
// 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 true` binds "true" as the
|
||||
// profile name (not as --quiet's value, which would leave apply-profile with no name).
|
||||
var parsed = Parse("apply-profile", "--quiet", "true");
|
||||
|
||||
Assert.AreEqual(0, parsed.Errors.Count, "--quiet must not consume the profile name");
|
||||
Assert.AreEqual("true", parsed.GetValueForArgument(CliOptions.ProfileName));
|
||||
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})");
|
||||
}
|
||||
}
|
||||
@@ -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.ProfileName);
|
||||
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(string profileName) => new()
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileName = profileName },
|
||||
};
|
||||
}
|
||||
175
src/modules/powerdisplay/PowerDisplay.Cli/Ipc/IpcDispatcher.cs
Normal file
175
src/modules/powerdisplay/PowerDisplay.Cli/Ipc/IpcDispatcher.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
// 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 the one success envelope whose exit code is data-driven: it returns the
|
||||
// worst-outcome code carried by the DTO (0=Ok, 2=OutOfRange, 3=InvalidDiscreteValue,
|
||||
// 5=HardwareFailure) instead of a constant Ok, so partial failures are not lost.
|
||||
public Task<int> SendApplyProfileAsync(CliRequestEnvelope envelope, CancellationToken ct)
|
||||
=> SendAndRenderAsync(envelope, ContractsJsonContext.Default.CliApplyProfileResult, _output.WriteApplyProfileResult, result => result.ExitCode, ct);
|
||||
|
||||
// Most success envelopes map to exit 0; SendApplyProfileAsync above is the only data-driven one.
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
172
src/modules/powerdisplay/PowerDisplay.Cli/Options/CliOptions.cs
Normal file
172
src/modules/powerdisplay/PowerDisplay.Cli/Options/CliOptions.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
// 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.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(
|
||||
["--monitor-number", "-n"],
|
||||
"Index of the monitor (1-based). 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<string> ProfileName = new(
|
||||
"name",
|
||||
"Name of the profile to apply (case-insensitive). 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,236 @@
|
||||
// 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($"{"Name",-24} {"Monitors",-9} {"Last modified"}");
|
||||
foreach (var p in result.Profiles)
|
||||
{
|
||||
var name = Truncate(p.Name, 24);
|
||||
_stdout.WriteLine($"{name,-24} {p.MonitorCount,-9} {p.LastModified}");
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteApplyProfileResult(CliApplyProfileResult result)
|
||||
{
|
||||
_stdout.WriteLine(Resources.Text_AppliedProfile(result.Profile));
|
||||
foreach (var m in result.Monitors)
|
||||
{
|
||||
if (!m.Connected)
|
||||
{
|
||||
_stdout.WriteLine($" Monitor {m.Monitor.Id}: {Resources.Text_NotConnectedSkipped}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var label = MonitorLabel(m.Monitor);
|
||||
if (m.Changes.Count == 0)
|
||||
{
|
||||
_stdout.WriteLine($" {label}: {Resources.Text_NoSettingsInProfile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var c in m.Changes)
|
||||
{
|
||||
var detail = c.Status switch
|
||||
{
|
||||
CliProfileChange.StatusApplied => $"{c.Setting} → {c.Display}",
|
||||
CliProfileChange.StatusUnsupported => $"{c.Setting} {Resources.Text_NotSupported}",
|
||||
CliProfileChange.StatusOutOfRange => $"{c.Setting} {c.Value} {Resources.Text_OutOfRangeSkipped}",
|
||||
CliProfileChange.StatusInvalidDiscreteValue => $"{c.Setting} {c.Value} {Resources.Text_InvalidValueSkipped}",
|
||||
CliProfileChange.StatusHardwareFailure => $"{c.Setting} → {c.Value} {Resources.Text_Failed} ({c.Error})",
|
||||
_ => $"{c.Setting}: {c.Status}",
|
||||
};
|
||||
_stdout.WriteLine($" {label}: {detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,56 @@
|
||||
<!-- 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. -->
|
||||
</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>
|
||||
445
src/modules/powerdisplay/PowerDisplay.Cli/Program.cs
Normal file
445
src/modules/powerdisplay/PowerDisplay.Cli/Program.cs
Normal file
@@ -0,0 +1,445 @@
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
|
||||
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));
|
||||
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 monitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber);
|
||||
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
|
||||
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
|
||||
|
||||
// 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 inputs = new SetCommandInputs
|
||||
{
|
||||
MonitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber),
|
||||
MonitorId = parseResult.GetValueForOption(CliOptions.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.
|
||||
var selected = SetCommand.CountSelectedSettings(inputs);
|
||||
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, inputs.MonitorNumber, inputs.MonitorId);
|
||||
|
||||
return await dispatcher.SendSetAsync(CliRequestBuilder.BuildSet(inputs), cancellationToken);
|
||||
}
|
||||
|
||||
// ── up / down ─────────────────────────────────────────────────────
|
||||
case CliCommandNames.Up:
|
||||
case CliCommandNames.Down:
|
||||
{
|
||||
var inputs = new AdjustCommandInputs
|
||||
{
|
||||
MonitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber),
|
||||
MonitorId = parseResult.GetValueForOption(CliOptions.MonitorId),
|
||||
Brightness = parseResult.GetValueForOption(CliOptions.BrightnessFlag),
|
||||
Contrast = parseResult.GetValueForOption(CliOptions.ContrastFlag),
|
||||
Volume = parseResult.GetValueForOption(CliOptions.VolumeFlag),
|
||||
Step = parseResult.GetValueForOption(CliOptions.Step),
|
||||
};
|
||||
|
||||
var commandName = parseResult.CommandResult.Command.Name;
|
||||
|
||||
// CLI-side syntactic validation: exactly one continuous setting must be specified.
|
||||
var selected = AdjustCommand.CountSelectedSettings(inputs);
|
||||
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, inputs.MonitorNumber, inputs.MonitorId);
|
||||
|
||||
return await dispatcher.SendAdjustAsync(CliRequestBuilder.BuildAdjust(commandName, inputs), cancellationToken);
|
||||
}
|
||||
|
||||
// ── capabilities ──────────────────────────────────────────────────
|
||||
case CliCommandNames.Capabilities:
|
||||
{
|
||||
var monitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber);
|
||||
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
|
||||
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
|
||||
|
||||
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 profileName = parseResult.GetValueForArgument(CliOptions.ProfileName);
|
||||
return await dispatcher.SendApplyProfileAsync(
|
||||
CliRequestBuilder.BuildApplyProfile(profileName),
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasHelpToken(ParseResult parseResult)
|
||||
=> parseResult.UnmatchedTokens.Any(IsHelpToken)
|
||||
|| HelpBoundToProfileNameArgument(parseResult);
|
||||
|
||||
private static bool IsHelpToken(string token)
|
||||
=> token is "--help" or "-h" or "-?" or "/?";
|
||||
|
||||
// The `apply-profile <name>` positional argument greedily captures a "--help" token (it binds to
|
||||
// the argument, so it never reaches UnmatchedTokens). Without this, `apply-profile --help` would
|
||||
// be dispatched as "apply a profile literally named --help" instead of printing help like every
|
||||
// other command. Option *values* that look like help (e.g. `set -i -h`) are unaffected: they are
|
||||
// matched to an option, not to this argument.
|
||||
private static bool HelpBoundToProfileNameArgument(ParseResult parseResult)
|
||||
=> parseResult.CommandResult.Command.Name == CliCommandNames.ApplyProfile
|
||||
&& IsHelpToken(parseResult.GetValueForArgument(CliOptions.ProfileName) ?? string.Empty);
|
||||
|
||||
public static bool HasVersionToken(ParseResult parseResult)
|
||||
=> parseResult.UnmatchedTokens.Any(t => t == "--version");
|
||||
|
||||
public static bool IsVersionRequest(ParseResult parseResult)
|
||||
=> (HasVersionToken(parseResult) && parseResult.CommandResult.Command is RootCommand)
|
||||
|| VersionBoundToProfileNameArgument(parseResult);
|
||||
|
||||
// Mirror of HelpBoundToProfileNameArgument for "--version": the `apply-profile <name>` positional
|
||||
// argument greedily captures a "--version" token (it binds to the argument, so it never reaches
|
||||
// UnmatchedTokens and IsVersionRequest's RootCommand gate cannot see it). Without this,
|
||||
// `apply-profile --version` would be dispatched as "apply a profile literally named --version".
|
||||
private static bool VersionBoundToProfileNameArgument(ParseResult parseResult)
|
||||
=> parseResult.CommandResult.Command.Name == CliCommandNames.ApplyProfile
|
||||
&& parseResult.GetValueForArgument(CliOptions.ProfileName) == "--version";
|
||||
|
||||
/// <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.
|
||||
private static CliErrorResult BuildTimeoutErrorResult(string command, bool timedOut)
|
||||
=> 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,185 @@
|
||||
// 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));
|
||||
|
||||
// ---- 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,293 @@
|
||||
<?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="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>profile {0} not found</value>
|
||||
<comment>{0} = the profile name the user passed (not translated).</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,407 @@
|
||||
// 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_with_outcomes()
|
||||
{
|
||||
var result = new CliApplyProfileResult
|
||||
{
|
||||
Profile = "Gaming",
|
||||
Monitors = new List<CliProfileMonitorOutcome>
|
||||
{
|
||||
new CliProfileMonitorOutcome
|
||||
{
|
||||
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
|
||||
Connected = true,
|
||||
Changes = new List<CliProfileChange>
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 80, Display = "80%", Status = CliProfileChange.StatusApplied },
|
||||
new CliProfileChange { Setting = "volume", Value = 0, Status = CliProfileChange.StatusUnsupported },
|
||||
},
|
||||
},
|
||||
new CliProfileMonitorOutcome
|
||||
{
|
||||
Monitor = new CliMonitorRef { Number = 2, Id = "MON2", Name = "Monitor B" },
|
||||
Connected = false,
|
||||
Changes = new List<CliProfileChange>(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
|
||||
Assert.IsNotNull(back);
|
||||
Assert.AreEqual(CliExitCodes.Ok, back!.ExitCode);
|
||||
Assert.AreEqual("apply-profile", back.Command);
|
||||
Assert.AreEqual("Gaming", back.Profile);
|
||||
Assert.AreEqual(2, back.Monitors.Count);
|
||||
Assert.IsTrue(back.Monitors[0].Connected);
|
||||
Assert.AreEqual(2, back.Monitors[0].Changes.Count);
|
||||
Assert.AreEqual(CliProfileChange.StatusApplied, back.Monitors[0].Changes[0].Status);
|
||||
Assert.AreEqual("80%", back.Monitors[0].Changes[0].Display);
|
||||
Assert.AreEqual(CliProfileChange.StatusUnsupported, back.Monitors[0].Changes[1].Status);
|
||||
Assert.IsFalse(back.Monitors[1].Connected);
|
||||
Assert.AreEqual(0, back.Monitors[1].Changes.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CliApplyProfileResult_ExitCode_survives_round_trip()
|
||||
{
|
||||
// Verify that a non-default ExitCode (OutOfRange=2) survives JSON serialization/
|
||||
// deserialization. This is the Contracts-layer gate for the apply-profile exit-code bug fix.
|
||||
var result = new CliApplyProfileResult
|
||||
{
|
||||
ExitCode = CliExitCodes.OutOfRange,
|
||||
Profile = "Night",
|
||||
Monitors = new List<CliProfileMonitorOutcome>
|
||||
{
|
||||
new CliProfileMonitorOutcome
|
||||
{
|
||||
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
|
||||
Connected = true,
|
||||
Changes = new List<CliProfileChange>
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 110, Status = CliProfileChange.StatusOutOfRange },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
|
||||
Assert.IsNotNull(back);
|
||||
Assert.IsFalse(back!.IsError, "an apply-profile partial failure is still a success envelope (isError=false)");
|
||||
Assert.AreEqual(CliExitCodes.OutOfRange, back.ExitCode, "ExitCode=2 (OutOfRange) must survive the JSON round-trip");
|
||||
Assert.AreEqual("Night", 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 { ProfileName = "Gaming" },
|
||||
};
|
||||
|
||||
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("Gaming", back.ApplyProfile!.ProfileName);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
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"/>
|
||||
/// plus the structured fields below; the CLI composes the localized, human-readable message from
|
||||
/// <see cref="Code"/> (see <c>Resources</c>). This is populated only as a last-resort fallback for
|
||||
/// a <see cref="Code"/> 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="Code"/>; used only as a fallback for an
|
||||
/// unrecognized <see cref="Code"/>.
|
||||
/// </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,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.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Contracts;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(CliRequestEnvelope))]
|
||||
[JsonSerializable(typeof(CliListResult))]
|
||||
[JsonSerializable(typeof(CliGetResult))]
|
||||
[JsonSerializable(typeof(CliSetResult))]
|
||||
[JsonSerializable(typeof(CliCapabilitiesResult))]
|
||||
[JsonSerializable(typeof(CliProfileListResult))]
|
||||
[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 string ProfileName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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,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 CliApplyProfileResult
|
||||
{
|
||||
// Response discriminator (see CliResponseHeader): false even on a partial failure — an
|
||||
// apply-profile result is a success envelope; the dispatcher reads ExitCode for the outcome.
|
||||
public bool IsError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The process exit code that reflects the worst outcome across all applied settings.
|
||||
/// Precedence: HardwareFailure (5) > InvalidDiscreteValue (3) > OutOfRange (2) > Ok (0).
|
||||
/// Defaults to <see cref="CliExitCodes.Ok"/> (0) when all settings applied successfully.
|
||||
/// </summary>
|
||||
public int ExitCode { get; init; } = CliExitCodes.Ok;
|
||||
|
||||
public string Version { get; init; } = CliSchema.Version;
|
||||
|
||||
public string Command { get; init; } = CliCommandNames.ApplyProfile;
|
||||
|
||||
public string Profile { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<CliProfileMonitorOutcome> Monitors { get; init; } = [];
|
||||
}
|
||||
@@ -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,36 @@
|
||||
// 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>
|
||||
/// The result of applying one setting from a profile to one monitor.
|
||||
/// </summary>
|
||||
public sealed class CliProfileChange
|
||||
{
|
||||
public const string StatusApplied = "applied";
|
||||
public const string StatusUnsupported = "unsupported";
|
||||
public const string StatusOutOfRange = "out-of-range";
|
||||
|
||||
// A discrete value (color-temperature) that parses as a byte but is not in the monitor's
|
||||
// advertised supported set. Distinct from out-of-range (raw byte bounds) so apply-profile maps
|
||||
// it to the same exit code (3 / INVALID_DISCRETE_VALUE) the `set` command uses for that case.
|
||||
public const string StatusInvalidDiscreteValue = "invalid-discrete-value";
|
||||
|
||||
public const string StatusHardwareFailure = "hardware-failure";
|
||||
|
||||
public string Setting { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>The raw value the profile requested (percentage for continuous, VCP value for color-temperature).</summary>
|
||||
public int Value { get; init; }
|
||||
|
||||
/// <summary>Human-readable applied value (e.g. "50%", "6500K (0x05)"); present only when <see cref="Status"/> is "applied".</summary>
|
||||
public string? Display { get; init; }
|
||||
|
||||
/// <summary>One of applied / unsupported / out-of-range / invalid-discrete-value / hardware-failure.</summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hardware error message; present only when <see cref="Status"/> is "hardware-failure".</summary>
|
||||
public string? Error { 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;
|
||||
|
||||
/// <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 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,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;
|
||||
|
||||
namespace PowerDisplay.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Per-monitor outcome of an <c>apply-profile</c> run.
|
||||
/// </summary>
|
||||
public sealed class CliProfileMonitorOutcome
|
||||
{
|
||||
public CliMonitorRef Monitor { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// False when the profile names a monitor that is not currently connected (or is hidden);
|
||||
/// in that case <see cref="Changes"/> is empty and nothing was written.
|
||||
/// </summary>
|
||||
public bool Connected { get; init; }
|
||||
|
||||
public IReadOnlyList<CliProfileChange> Changes { 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,250 @@
|
||||
// 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;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Contracts;
|
||||
using PowerDisplay.Ipc;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AdjustCommandExecutor"/> (relative up/down on continuous settings).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AdjustCommandExecutorTests
|
||||
{
|
||||
private const int DefaultStep = 5;
|
||||
|
||||
private 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);
|
||||
}
|
||||
|
||||
// ─── Fakes ────────────────────────────────────────────────────────────────
|
||||
private 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());
|
||||
}
|
||||
|
||||
private sealed class FailingManager : IMonitorManager
|
||||
{
|
||||
public Task<MonitorOperationResult> SetBrightnessAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetContrastAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetPowerStateAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
|
||||
public Task<MonitorOperationResult> SetRotationAsync(string id, int v, CancellationToken ct = default) => Task.FromResult(MonitorOperationResult.Failure("simulated hardware failure"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Contracts;
|
||||
using PowerDisplay.ViewModels;
|
||||
|
||||
namespace PowerDisplay.Ipc.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MainViewModel.TryRestoreWithOutcomeAsync"/>, the apply-profile outcomes
|
||||
/// validator. Focused on the discrete (color-temperature) supported-set check that makes
|
||||
/// apply-profile agree with the <c>set</c> command.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ApplyProfileOutcomeTests
|
||||
{
|
||||
private static readonly int[] ColorPresetSet = { 0x01, 0x05 };
|
||||
|
||||
private int _applyCalls;
|
||||
|
||||
private Task<MonitorOperationResult> RecordingApply(string id, int value, CancellationToken ct)
|
||||
{
|
||||
_applyCalls++;
|
||||
return Task.FromResult(MonitorOperationResult.Success());
|
||||
}
|
||||
|
||||
private Task<CliProfileChange?> RunColorTemp(int value, IReadOnlyList<int>? supportedValues)
|
||||
=> MainViewModel.TryRestoreWithOutcomeAsync(
|
||||
savedValue: value,
|
||||
supportsHardware: true,
|
||||
settingName: CliSettingNames.ColorTemperature,
|
||||
monitorId: "MON1",
|
||||
formatDisplay: v => $"0x{v:X2}",
|
||||
applyAsync: RecordingApply,
|
||||
supportedValues: supportedValues,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorTemperature_ValueNotInSupportedSet_ReportsInvalidDiscreteValue_AndSkipsWrite()
|
||||
{
|
||||
// A valid byte that the monitor does not advertise must be rejected before any hardware write
|
||||
// and reported as INVALID_DISCRETE_VALUE — the same classification (and exit code 3) the
|
||||
// `set` command uses, not OUT_OF_RANGE (exit 2).
|
||||
var outcome = await RunColorTemp(0x99, ColorPresetSet);
|
||||
|
||||
Assert.IsNotNull(outcome);
|
||||
Assert.AreEqual(CliProfileChange.StatusInvalidDiscreteValue, outcome!.Status);
|
||||
Assert.AreEqual(0, _applyCalls, "hardware write must not be attempted for an unsupported value");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorTemperature_OutOfByteRange_ReportsOutOfRange_AndSkipsWrite()
|
||||
{
|
||||
// A value outside the VCP byte bounds [0, 0xFF] is OUT_OF_RANGE (exit 2), distinct from a
|
||||
// valid-byte-but-unsupported value. Uses a null advertised set so only the byte-range guard
|
||||
// can fire (the previously-untested branch).
|
||||
var outcome = await RunColorTemp(0x100, supportedValues: null);
|
||||
|
||||
Assert.IsNotNull(outcome);
|
||||
Assert.AreEqual(CliProfileChange.StatusOutOfRange, outcome!.Status);
|
||||
Assert.AreEqual(0, _applyCalls, "hardware write must not be attempted for an out-of-range value");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorTemperature_ValueInSupportedSet_Applies()
|
||||
{
|
||||
var outcome = await RunColorTemp(0x05, ColorPresetSet);
|
||||
|
||||
Assert.IsNotNull(outcome);
|
||||
Assert.AreEqual(CliProfileChange.StatusApplied, outcome!.Status);
|
||||
Assert.AreEqual(1, _applyCalls);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorTemperature_NoAdvertisedSet_AppliesWithinByteRange()
|
||||
{
|
||||
// Monitor did not advertise a set → fall back to the byte-range guard (write proceeds).
|
||||
var outcome = await RunColorTemp(0x05, supportedValues: null);
|
||||
|
||||
Assert.IsNotNull(outcome);
|
||||
Assert.AreEqual(CliProfileChange.StatusApplied, outcome!.Status);
|
||||
Assert.AreEqual(1, _applyCalls);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Continuous_OutOfRangeValue_ReportsOutOfRange_AndSkipsWrite()
|
||||
{
|
||||
var outcome = await MainViewModel.TryRestoreWithOutcomeAsync(
|
||||
savedValue: 150,
|
||||
supportsHardware: true,
|
||||
settingName: CliSettingNames.Brightness,
|
||||
monitorId: "MON1",
|
||||
formatDisplay: v => v + "%",
|
||||
applyAsync: RecordingApply,
|
||||
supportedValues: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.IsNotNull(outcome);
|
||||
Assert.AreEqual(CliProfileChange.StatusOutOfRange, outcome!.Status);
|
||||
Assert.AreEqual(0, _applyCalls);
|
||||
}
|
||||
}
|
||||
@@ -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.IO;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
// 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.Common.Services;
|
||||
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>
|
||||
/// Fake no-op <see cref="IMonitorManager"/> that records set calls without performing hardware writes.
|
||||
/// </summary>
|
||||
private sealed class FakeManager : IMonitorManager
|
||||
{
|
||||
public bool FailWrites { get; set; }
|
||||
|
||||
public string FailureMessage { get; set; } = "hardware error";
|
||||
|
||||
public Task<MonitorOperationResult> SetBrightnessAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetContrastAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetPowerStateAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
public Task<MonitorOperationResult> SetRotationAsync(string id, int v, CancellationToken ct = default)
|
||||
=> Respond();
|
||||
|
||||
private Task<MonitorOperationResult> Respond()
|
||||
=> Task.FromResult(FailWrites
|
||||
? MonitorOperationResult.Failure(FailureMessage)
|
||||
: MonitorOperationResult.Success());
|
||||
}
|
||||
|
||||
/// <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 a single monitor snapshot and no hidden IDs.
|
||||
/// </summary>
|
||||
private static Task<string> Dispatch(
|
||||
CliRequestEnvelope envelope,
|
||||
IReadOnlyList<Monitor>? monitors = null,
|
||||
PowerDisplayProfiles? profiles = null,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<ProfileApplyOutcome>?>>? applyProfile = null,
|
||||
int defaultStep = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return CliRequestHandler.BuildResponseAsync(
|
||||
envelope,
|
||||
monitors ?? new[] { MakeMon() },
|
||||
NoHidden,
|
||||
Array.Empty<CustomVcpValueMapping>(),
|
||||
new FakeManager(),
|
||||
defaultStep,
|
||||
() => profiles ?? EmptyProfiles,
|
||||
applyProfile ?? ((_, _) => Task.FromResult<IReadOnlyList<ProfileApplyOutcome>?>(Array.Empty<ProfileApplyOutcome>())),
|
||||
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()
|
||||
{
|
||||
var outcomes = new ProfileApplyOutcome[]
|
||||
{
|
||||
new ProfileApplyOutcome("A", Connected: true, Changes: Array.Empty<CliProfileChange>()),
|
||||
};
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<ProfileApplyOutcome>?>> applyFn =
|
||||
(_, _) => Task.FromResult<IReadOnlyList<ProfileApplyOutcome>?>(outcomes);
|
||||
|
||||
var envelope = new CliRequestEnvelope
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileName = "Night" },
|
||||
};
|
||||
|
||||
var json = await Dispatch(envelope, applyProfile: applyFn);
|
||||
|
||||
var result = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
Assert.IsNotNull(result, "should deserialize to CliApplyProfileResult");
|
||||
Assert.AreEqual("apply-profile", result.Command);
|
||||
Assert.AreEqual("Night", result.Profile);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ApplyProfile_ProfileNotFound_ReturnsArgumentError()
|
||||
{
|
||||
// null outcomes = profile not found
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<ProfileApplyOutcome>?>> applyFn =
|
||||
(_, _) => Task.FromResult<IReadOnlyList<ProfileApplyOutcome>?>(null);
|
||||
|
||||
var envelope = new CliRequestEnvelope
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileName = "NoSuchProfile" },
|
||||
};
|
||||
|
||||
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("NoSuchProfile", error.Error.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ApplyProfile_EmptyProfileName_ReturnsArgumentError()
|
||||
{
|
||||
var envelope = new CliRequestEnvelope
|
||||
{
|
||||
Command = CliCommandNames.ApplyProfile,
|
||||
ApplyProfile = new ApplyProfileRequest { ProfileName = string.Empty },
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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,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,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,321 @@
|
||||
// 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;
|
||||
using PowerDisplay.ViewModels;
|
||||
|
||||
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!));
|
||||
}
|
||||
|
||||
// ─── BuildApplyProfileResult — exit-code aggregation ─────────────────────
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_AllApplied_ExitCodeOk()
|
||||
{
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 50, Display = "50%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
new CliProfileChange { Setting = "contrast", Value = 70, Display = "70%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Day", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.Ok, result.ExitCode);
|
||||
Assert.AreEqual("Day", result.Profile);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_WorstOutcome_HardwareFailure_ExitCodeHardwareFailure()
|
||||
{
|
||||
// One monitor applied OK, another has a hardware failure → worst = HardwareFailure.
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 50, Display = "50%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
}),
|
||||
new("MON-B", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "contrast", Value = 70, Display = null, Status = CliProfileChange.StatusHardwareFailure, Error = "DDC write timed out" },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Night", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.HardwareFailure, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_OutOfRange_ExitCodeOutOfRange()
|
||||
{
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 50, Display = "50%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
new CliProfileChange { Setting = "volume", Value = 150, Display = null, Status = CliProfileChange.StatusOutOfRange, Error = null },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Cinema", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.OutOfRange, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_HardwareFailureDominatesOutOfRange()
|
||||
{
|
||||
// HardwareFailure must win over OutOfRange regardless of order.
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 150, Display = null, Status = CliProfileChange.StatusOutOfRange, Error = null },
|
||||
new CliProfileChange { Setting = "contrast", Value = 70, Display = null, Status = CliProfileChange.StatusHardwareFailure, Error = "I2C error" },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.HardwareFailure, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_InvalidDiscreteValue_ExitCodeInvalidDiscreteValue()
|
||||
{
|
||||
// An out-of-supported-set color-temperature value maps to INVALID_DISCRETE_VALUE (exit 3),
|
||||
// matching the `set` command's classification rather than OUT_OF_RANGE (exit 2).
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "color-temperature", Value = 0x99, Display = null, Status = CliProfileChange.StatusInvalidDiscreteValue, Error = null },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_InvalidDiscreteValueDominatesOutOfRange()
|
||||
{
|
||||
// Precedence: InvalidDiscreteValue (3) wins over OutOfRange (2), regardless of order.
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 150, Display = null, Status = CliProfileChange.StatusOutOfRange, Error = null },
|
||||
new CliProfileChange { Setting = "color-temperature", Value = 0x99, Display = null, Status = CliProfileChange.StatusInvalidDiscreteValue, Error = null },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_HardwareFailureDominatesInvalidDiscreteValue()
|
||||
{
|
||||
// Precedence: HardwareFailure (5) wins over InvalidDiscreteValue (3).
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "color-temperature", Value = 0x99, Display = null, Status = CliProfileChange.StatusInvalidDiscreteValue, Error = null },
|
||||
new CliProfileChange { Setting = "contrast", Value = 70, Display = null, Status = CliProfileChange.StatusHardwareFailure, Error = "I2C error" },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.HardwareFailure, result.ExitCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_ConnectedMonitor_CarriesNumberAndName()
|
||||
{
|
||||
// A connected outcome carries the monitor's real number/name so the renderer prints
|
||||
// "Monitor 2 (Dell U2720Q)" rather than the placeholder "Monitor 0 ()".
|
||||
var changes = new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 50, Display = "50%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
};
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: changes, Number: 2, Name: "Dell U2720Q"),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
var mon = result.Monitors[0].Monitor;
|
||||
Assert.AreEqual("MON-A", mon.Id);
|
||||
Assert.AreEqual(2, mon.Number);
|
||||
Assert.AreEqual("Dell U2720Q", mon.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_UnsupportedOnly_ExitCodeOk()
|
||||
{
|
||||
// "unsupported" does NOT contribute to exit-code failures (mirrors ApplyProfileCommand).
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 50, Display = null, Status = CliProfileChange.StatusUnsupported, Error = null },
|
||||
new CliProfileChange { Setting = "contrast", Value = 70, Display = null, Status = CliProfileChange.StatusUnsupported, Error = null },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.Ok, result.ExitCode);
|
||||
}
|
||||
|
||||
// ─── BuildApplyProfileResult — unconnected monitor ────────────────────────
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_UnconnectedMonitor_ConnectedFalseNoChanges()
|
||||
{
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-OFFLINE", Connected: false, Changes: Array.Empty<CliProfileChange>()),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.Ok, result.ExitCode);
|
||||
Assert.AreEqual(1, result.Monitors.Count);
|
||||
|
||||
var mon = result.Monitors[0];
|
||||
Assert.IsFalse(mon.Connected);
|
||||
Assert.AreEqual("MON-OFFLINE", mon.Monitor.Id);
|
||||
Assert.AreEqual(0, mon.Changes.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_MixedConnectedUnconnected_CorrectOutcomes()
|
||||
{
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[] { new CliProfileChange { Setting = "brightness", Value = 50, Display = "50%", Status = CliProfileChange.StatusApplied, Error = null } }),
|
||||
new("MON-OFFLINE", Connected: false, Changes: Array.Empty<CliProfileChange>()),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
Assert.AreEqual(CliExitCodes.Ok, result.ExitCode);
|
||||
Assert.AreEqual(2, result.Monitors.Count);
|
||||
Assert.IsTrue(result.Monitors[0].Connected);
|
||||
Assert.IsFalse(result.Monitors[1].Connected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_NullOutcomes_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.ThrowsException<ArgumentNullException>(
|
||||
() => ProfileDtoProjector.BuildApplyProfileResult("Profile", null!));
|
||||
}
|
||||
|
||||
// ─── BuildApplyProfileResult — DTO field correctness ────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The projector copies each <see cref="CliProfileChange"/> row verbatim (it only reads Status
|
||||
/// for the worst-outcome exit code), so one representative outcome pins the full per-row field
|
||||
/// pass-through: Setting, Status, Value, Display (populated and null), and Error (null and
|
||||
/// populated). Per-status exit-code behavior is covered by the exit-code tests above.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void BuildApplyProfileResult_ChangeRowsCarryAllFieldsVerbatim()
|
||||
{
|
||||
const string errorMsg = "DDC SetVCP returned error 0x8";
|
||||
var outcomes = new List<ProfileApplyOutcome>
|
||||
{
|
||||
new("MON-A", Connected: true, Changes: new[]
|
||||
{
|
||||
new CliProfileChange { Setting = "brightness", Value = 75, Display = "75%", Status = CliProfileChange.StatusApplied, Error = null },
|
||||
new CliProfileChange { Setting = "contrast", Value = 60, Display = null, Status = CliProfileChange.StatusHardwareFailure, Error = errorMsg },
|
||||
}),
|
||||
};
|
||||
|
||||
var result = ProfileDtoProjector.BuildApplyProfileResult("Profile", outcomes);
|
||||
|
||||
var changes = result.Monitors[0].Changes;
|
||||
Assert.AreEqual(2, changes.Count);
|
||||
|
||||
// Applied row: Value + Display carried, Error null.
|
||||
Assert.AreEqual("brightness", changes[0].Setting);
|
||||
Assert.AreEqual(CliProfileChange.StatusApplied, changes[0].Status);
|
||||
Assert.AreEqual(75, changes[0].Value);
|
||||
Assert.AreEqual("75%", changes[0].Display);
|
||||
Assert.IsNull(changes[0].Error);
|
||||
|
||||
// Hardware-failure row: Value + Error carried, Display null.
|
||||
Assert.AreEqual("contrast", changes[1].Setting);
|
||||
Assert.AreEqual(CliProfileChange.StatusHardwareFailure, changes[1].Status);
|
||||
Assert.AreEqual(60, changes[1].Value);
|
||||
Assert.IsNull(changes[1].Display);
|
||||
Assert.AreEqual(errorMsg, changes[1].Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// 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;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Contracts;
|
||||
using PowerDisplay.Ipc;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="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);
|
||||
}
|
||||
|
||||
// ─── Fake IMonitorManager implementations ────────────────────────────────
|
||||
/// <summary>Always returns Success for all write operations.</summary>
|
||||
private sealed class NoOpManager : IMonitorManager
|
||||
{
|
||||
public Task<MonitorOperationResult> SetBrightnessAsync(string monitorId, int brightness, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetContrastAsync(string monitorId, int contrast, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(string monitorId, int volume, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(string monitorId, int colorTemperature, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(string monitorId, int inputSource, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetPowerStateAsync(string monitorId, int powerState, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
|
||||
public Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Success());
|
||||
}
|
||||
|
||||
/// <summary>Always returns Failure for all write operations.</summary>
|
||||
private sealed class FailingManager : IMonitorManager
|
||||
{
|
||||
private readonly string _errorMessage;
|
||||
|
||||
public FailingManager(string errorMessage = "simulated hardware failure")
|
||||
{
|
||||
_errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public Task<MonitorOperationResult> SetBrightnessAsync(string monitorId, int brightness, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetContrastAsync(string monitorId, int contrast, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(string monitorId, int volume, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(string monitorId, int colorTemperature, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(string monitorId, int inputSource, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetPowerStateAsync(string monitorId, int powerState, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
|
||||
public Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MonitorOperationResult.Failure(_errorMessage));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
273
src/modules/powerdisplay/PowerDisplay/Ipc/CliPipeServer.cs
Normal file
273
src/modules/powerdisplay/PowerDisplay/Ipc/CliPipeServer.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
// 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.
|
||||
/// <see cref="NamedPipeServerStream.MaxAllowedServerInstances"/> is passed only to avoid an
|
||||
/// artificial single-instance cap, 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}'");
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// FirstPipeInstance makes Create fail (rather than silently join) if another process
|
||||
// already owns this pipe name. The name is predictable (session id), so without it a
|
||||
// same-user process could pre-create the pipe and intercept/spoof CLI traffic — the
|
||||
// current-user ACL below would be moot. The accept loop's catch surfaces the failure
|
||||
// and backs off.
|
||||
using var server = NamedPipeServerStreamAcl.Create(
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance,
|
||||
inBufferSize: 0,
|
||||
outBufferSize: 0,
|
||||
security);
|
||||
|
||||
await server.WaitForConnectionAsync(ct).ConfigureAwait(false);
|
||||
await ServeOneAsync(server, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[PowerDisplay CLI IPC] server loop error: {ex.GetType().Name}: {ex.Message}");
|
||||
|
||||
// Continue — a single bad connection must not kill the server. Back off briefly so a
|
||||
// persistent create failure (e.g. another process holding the pipe name, which
|
||||
// FirstPipeInstance now surfaces as an exception instead of silently joining it) does
|
||||
// not spin the loop.
|
||||
try
|
||||
{
|
||||
await Task.Delay(500, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogInfo("[PowerDisplay CLI IPC] Server stopped.");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
395
src/modules/powerdisplay/PowerDisplay/Ipc/CliRequestHandler.cs
Normal file
395
src/modules/powerdisplay/PowerDisplay/Ipc/CliRequestHandler.cs
Normal file
@@ -0,0 +1,395 @@
|
||||
// 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.Text.Json.Serialization.Metadata;
|
||||
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"/>, dispatches it to the appropriate
|
||||
/// projector/executor 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.
|
||||
var timeoutErr = MakeError("unknown", CliErrorCodes.Timeout, "request timed out or was cancelled");
|
||||
return Serialize(timeoutErr, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[CliRequestHandler] Unexpected exception in HandleAsync: {ex.GetType().Name}: {ex.Message}");
|
||||
var errorResult = MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: ex.Message);
|
||||
return Serialize(errorResult, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 name and returns structured outcomes, or null when the
|
||||
/// profile is not found. Receives the profile name and a <see cref="CancellationToken"/>.
|
||||
/// Maps to <c>MainViewModel.ApplyProfileWithOutcomesAsync</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<string, CancellationToken, Task<IReadOnlyList<ProfileApplyOutcome>?>> applyProfileAsync,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (envelope.Command)
|
||||
{
|
||||
// ── list ──────────────────────────────────────────────────────────
|
||||
case CliCommandNames.List:
|
||||
{
|
||||
var result = MonitorDtoProjector.BuildListResult(snapshot, hiddenIds);
|
||||
return Serialize(result, ContractsJsonContext.Default.CliListResult);
|
||||
}
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────
|
||||
case CliCommandNames.Get:
|
||||
{
|
||||
var req = envelope.Get ?? new GetRequest();
|
||||
var (result, error) = MonitorDtoProjector.BuildGetResult(
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
req.MonitorNumber,
|
||||
req.MonitorId,
|
||||
req.SettingFilter,
|
||||
customMappings);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
return Serialize(error, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
return Serialize(result!, ContractsJsonContext.Default.CliGetResult);
|
||||
}
|
||||
|
||||
// ── set ───────────────────────────────────────────────────────────
|
||||
case CliCommandNames.Set:
|
||||
{
|
||||
if (envelope.Set is null)
|
||||
{
|
||||
return Serialize(MakeError(CliCommandNames.Set, CliErrorCodes.ArgumentError, "missing 'set' payload"), ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
var (result, error) = await SetCommandExecutor.ExecuteAsync(
|
||||
manager,
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
envelope.Set,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
return Serialize(error, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
return Serialize(result!, ContractsJsonContext.Default.CliSetResult);
|
||||
}
|
||||
|
||||
// ── up / down (relative adjust) ─────────────────────────────────────
|
||||
case CliCommandNames.Up:
|
||||
case CliCommandNames.Down:
|
||||
{
|
||||
if (envelope.Adjust is null)
|
||||
{
|
||||
return Serialize(MakeError(envelope.Command, CliErrorCodes.ArgumentError, "missing 'adjust' payload"), ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
var (result, error) = await AdjustCommandExecutor.ExecuteAsync(
|
||||
manager,
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
envelope.Adjust,
|
||||
isUp: envelope.Command == CliCommandNames.Up,
|
||||
defaultStep,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
return Serialize(error, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
return Serialize(result!, ContractsJsonContext.Default.CliSetResult);
|
||||
}
|
||||
|
||||
// ── capabilities ──────────────────────────────────────────────────
|
||||
case CliCommandNames.Capabilities:
|
||||
{
|
||||
var req = envelope.Capabilities ?? new CapabilitiesRequest();
|
||||
var (result, error) = MonitorDtoProjector.BuildCapabilitiesResult(
|
||||
snapshot,
|
||||
hiddenIds,
|
||||
req.MonitorNumber,
|
||||
req.MonitorId,
|
||||
req.SettingFilter,
|
||||
customMappings);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
return Serialize(error, ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
return Serialize(result!, ContractsJsonContext.Default.CliCapabilitiesResult);
|
||||
}
|
||||
|
||||
// ── profiles ──────────────────────────────────────────────────────
|
||||
case CliCommandNames.Profiles:
|
||||
{
|
||||
var result = ProfileDtoProjector.BuildProfileListResult(loadProfiles());
|
||||
return Serialize(result, ContractsJsonContext.Default.CliProfileListResult);
|
||||
}
|
||||
|
||||
// ── apply-profile ─────────────────────────────────────────────────
|
||||
case CliCommandNames.ApplyProfile:
|
||||
{
|
||||
var profileName = envelope.ApplyProfile?.ProfileName ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(profileName))
|
||||
{
|
||||
return Serialize(MakeError(CliCommandNames.ApplyProfile, CliErrorCodes.ArgumentError, "profile name must not be empty"), ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
var outcomes = await applyProfileAsync(profileName, ct).ConfigureAwait(false);
|
||||
|
||||
if (outcomes is null)
|
||||
{
|
||||
// Profile not found — return ARGUMENT_ERROR / exit code 7.
|
||||
return Serialize(
|
||||
MakeCodedError(
|
||||
CliCommandNames.ApplyProfile,
|
||||
CliErrorCodes.ArgumentError,
|
||||
CliMessageIds.ProfileNotFound,
|
||||
value: profileName),
|
||||
ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
var applyResult = ProfileDtoProjector.BuildApplyProfileResult(profileName, outcomes);
|
||||
return Serialize(applyResult, ContractsJsonContext.Default.CliApplyProfileResult);
|
||||
}
|
||||
|
||||
// ── unknown command ───────────────────────────────────────────────
|
||||
// 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).
|
||||
default:
|
||||
return Serialize(MakeCodedError(envelope.Command, CliErrorCodes.ArgumentError, CliMessageIds.UnknownCommand, value: envelope.Command), ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
}
|
||||
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 Serialize(
|
||||
MakeError(envelope.Command, CliErrorCodes.Timeout, "operation timed out or was cancelled"),
|
||||
ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 Serialize(MakeCodedError("unknown", CliErrorCodes.InternalError, CliMessageIds.InternalError, detail: "could not parse request envelope"), ContractsJsonContext.Default.CliErrorResult);
|
||||
}
|
||||
|
||||
// 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,
|
||||
(name, token) => _vm.ApplyProfileWithOutcomesAsync(name, 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;
|
||||
}
|
||||
|
||||
// ─── Serialization helper ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a response DTO to one-line JSON using its source-generated
|
||||
/// <see cref="JsonTypeInfo{T}"/> (AOT/trim safe). One generic helper replaces a per-type
|
||||
/// overload set, so a new result DTO needs no new method here.
|
||||
/// </summary>
|
||||
private static string Serialize<T>(T value, JsonTypeInfo<T> typeInfo)
|
||||
=> JsonSerializer.Serialize(value, typeInfo);
|
||||
|
||||
// ─── Error factory ────────────────────────────────────────────────────────
|
||||
private static CliErrorResult MakeError(string command, string code, string message, string? hint = null)
|
||||
=> new()
|
||||
{
|
||||
Command = command,
|
||||
Error = new CliError
|
||||
{
|
||||
Code = code,
|
||||
Message = message,
|
||||
Hint = hint,
|
||||
},
|
||||
};
|
||||
|
||||
// Code-only error: the app names the message via CliMessageIds and supplies structured data;
|
||||
// the CLI localizes the human-readable text. Value/Detail feed the localized template.
|
||||
private 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,95 @@
|
||||
// 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(
|
||||
CliSettingNames.Brightness,
|
||||
CliSettingKind.Continuous,
|
||||
NativeConstants.VcpCodeBrightness,
|
||||
MonitorReadFlags.Brightness,
|
||||
m => m.SupportsBrightness,
|
||||
m => m.CurrentBrightness,
|
||||
_ => null,
|
||||
(mm, id, v, c) => mm.SetBrightnessAsync(id, v, c),
|
||||
"monitor exposed neither a WMI brightness interface nor DDC/CI brightness (0x10)"),
|
||||
new(
|
||||
CliSettingNames.Contrast,
|
||||
CliSettingKind.Continuous,
|
||||
NativeConstants.VcpCodeContrast,
|
||||
MonitorReadFlags.Contrast,
|
||||
m => m.SupportsContrast,
|
||||
m => m.CurrentContrast,
|
||||
_ => null,
|
||||
(mm, id, v, c) => mm.SetContrastAsync(id, v, c),
|
||||
"monitor's VCP capabilities did not advertise contrast (0x12)"),
|
||||
new(
|
||||
CliSettingNames.Volume,
|
||||
CliSettingKind.Continuous,
|
||||
NativeConstants.VcpCodeVolume,
|
||||
MonitorReadFlags.Volume,
|
||||
m => m.SupportsVolume,
|
||||
m => m.CurrentVolume,
|
||||
_ => null,
|
||||
(mm, id, v, c) => mm.SetVolumeAsync(id, v, c),
|
||||
"monitor's VCP capabilities did not advertise audio speaker volume (0x62)"),
|
||||
new(
|
||||
CliSettingNames.ColorTemperature,
|
||||
CliSettingKind.Discrete,
|
||||
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(
|
||||
CliSettingNames.InputSource,
|
||||
CliSettingKind.Discrete,
|
||||
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(
|
||||
CliSettingNames.PowerState,
|
||||
CliSettingKind.Discrete,
|
||||
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);
|
||||
}
|
||||
53
src/modules/powerdisplay/PowerDisplay/Ipc/CliVcpSetting.cs
Normal file
53
src/modules/powerdisplay/PowerDisplay/Ipc/CliVcpSetting.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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 Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Static, per-setting metadata for one of the six VCP settings the CLI read/write commands operate
|
||||
/// on. One descriptor replaces the parallel hand-maintained switch arms previously spread across
|
||||
/// <see cref="MonitorDtoProjector"/>, <see cref="SetCommandExecutor"/>, and the apply-profile path,
|
||||
/// so adding or changing a setting touches a single row in <see cref="CliSettingCatalog"/>.
|
||||
/// <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>
|
||||
/// <param name="Name">Canonical (lower-case) setting name; see <c>CliSettingNames</c>.</param>
|
||||
/// <param name="Kind">Continuous percentage vs. discrete VCP value.</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="SupportedValues">
|
||||
/// Selects the monitor's advertised discrete value set (used to validate a <c>set</c> value).
|
||||
/// Returns <see langword="null"/> for continuous settings, which have no discrete set.
|
||||
/// </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>
|
||||
internal sealed record CliVcpSetting(
|
||||
string Name,
|
||||
CliSettingKind Kind,
|
||||
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);
|
||||
428
src/modules/powerdisplay/PowerDisplay/Ipc/MonitorDtoProjector.cs
Normal file
428
src/modules/powerdisplay/PowerDisplay/Ipc/MonitorDtoProjector.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Contracts;
|
||||
using PowerDisplay.Models;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function projector that turns the app's rich <see cref="Monitor"/> model into the flat
|
||||
/// Contracts result DTOs consumed by the CLI renderers. All three read-side commands (list, get,
|
||||
/// capabilities) are covered.
|
||||
/// <para>
|
||||
/// This projector is the single source of these DTOs: it defines the display strings, error
|
||||
/// codes/exit codes, and hidden-monitor and selector semantics that the CLI renderers consume.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MonitorDtoProjector
|
||||
{
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds the result DTO for the <c>list</c> command.
|
||||
/// Hidden monitors are excluded; each surviving monitor becomes one list entry.
|
||||
/// </summary>
|
||||
public static CliListResult BuildListResult(
|
||||
IReadOnlyList<Monitor> monitors,
|
||||
IReadOnlySet<string> hiddenIds)
|
||||
{
|
||||
var visible = ExcludeHidden(monitors, hiddenIds);
|
||||
var entries = new List<CliMonitorRef>(visible.Count);
|
||||
foreach (var m in visible)
|
||||
{
|
||||
entries.Add(ToRef(m));
|
||||
}
|
||||
|
||||
return new CliListResult { Monitors = entries };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the result DTO for the <c>get</c> command.
|
||||
/// <list type="bullet">
|
||||
/// <item>When no selector (<paramref name="number"/> and <paramref name="id"/> both null/empty)
|
||||
/// all visible monitors are returned.</item>
|
||||
/// <item>Otherwise the selector is resolved; if resolution fails an error DTO is returned.</item>
|
||||
/// <item>An unknown <paramref name="settingFilter"/> yields an <c>ARGUMENT_ERROR</c> error DTO.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static (CliGetResult? Result, CliErrorResult? Error) BuildGetResult(
|
||||
IReadOnlyList<Monitor> monitors,
|
||||
IReadOnlySet<string> hiddenIds,
|
||||
int? number,
|
||||
string? id,
|
||||
string? settingFilter,
|
||||
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
|
||||
{
|
||||
var visible = ExcludeHidden(monitors, hiddenIds);
|
||||
|
||||
if (!number.HasValue && string.IsNullOrEmpty(id))
|
||||
{
|
||||
if (TryGetUnknownSettingError(settingFilter, out _, out var settingErr))
|
||||
{
|
||||
return (null, new CliErrorResult { Command = CliCommandNames.Get, Error = settingErr! });
|
||||
}
|
||||
|
||||
var allEntries = new List<CliGetMonitorEntry>(visible.Count);
|
||||
foreach (var monitor in visible)
|
||||
{
|
||||
var monRef = ToRef(monitor);
|
||||
allEntries.Add(BuildGetEntry(monitor, monRef, settingFilter, customMappings, out _)!);
|
||||
}
|
||||
|
||||
return (new CliGetResult { Monitors = allEntries }, null);
|
||||
}
|
||||
|
||||
var (selected, resolveError) = ResolveMonitor(visible, number, id);
|
||||
if (resolveError is not null)
|
||||
{
|
||||
return (null, new CliErrorResult { Command = CliCommandNames.Get, Error = resolveError });
|
||||
}
|
||||
|
||||
var mRef = ToRef(selected!);
|
||||
var entry = BuildGetEntry(selected!, mRef, settingFilter, customMappings, out var settingError);
|
||||
if (settingError is not null)
|
||||
{
|
||||
return (null, new CliErrorResult { Command = CliCommandNames.Get, Monitor = mRef, Error = settingError });
|
||||
}
|
||||
|
||||
return (new CliGetResult { Monitors = [entry!] }, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the result DTO for the <c>capabilities</c> command.
|
||||
/// A selector is required; if missing or not found an error DTO is returned.
|
||||
/// </summary>
|
||||
public static (CliCapabilitiesResult? Result, CliErrorResult? Error) BuildCapabilitiesResult(
|
||||
IReadOnlyList<Monitor> monitors,
|
||||
IReadOnlySet<string> hiddenIds,
|
||||
int? number,
|
||||
string? id,
|
||||
string? settingFilter = null,
|
||||
IReadOnlyList<CustomVcpValueMapping>? customMappings = null)
|
||||
{
|
||||
var visible = ExcludeHidden(monitors, hiddenIds);
|
||||
|
||||
var (selected, resolveError) = ResolveMonitor(visible, number, id);
|
||||
if (resolveError is not null)
|
||||
{
|
||||
return (null, new CliErrorResult { Command = CliCommandNames.Capabilities, Error = resolveError });
|
||||
}
|
||||
|
||||
// Optional --setting filter: restrict the result to a single discrete setting's VCP code.
|
||||
byte? filterCode = null;
|
||||
if (settingFilter is not null)
|
||||
{
|
||||
filterCode = VcpCodeForDiscreteSetting(settingFilter);
|
||||
if (filterCode is null)
|
||||
{
|
||||
return (null, new CliErrorResult
|
||||
{
|
||||
Command = CliCommandNames.Capabilities,
|
||||
Error = new CliError
|
||||
{
|
||||
Code = CliErrorCodes.ArgumentError,
|
||||
MessageId = CliMessageIds.NotDiscreteSetting,
|
||||
Value = settingFilter,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var caps = selected!.VcpCapabilitiesInfo;
|
||||
var vcpCodes = new List<CliVcpCodeInfo>();
|
||||
|
||||
if (caps is not null)
|
||||
{
|
||||
foreach (var code in caps.GetSortedVcpCodes())
|
||||
{
|
||||
if (filterCode is not null && code.Code != filterCode.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
List<string>? discreteValues = null;
|
||||
if (code.HasDiscreteValues)
|
||||
{
|
||||
discreteValues = new List<string>(code.SupportedValues.Count);
|
||||
foreach (var v in code.SupportedValues)
|
||||
{
|
||||
discreteValues.Add(FormatDiscrete(code.Code, v, customMappings, selected.Id));
|
||||
}
|
||||
}
|
||||
|
||||
vcpCodes.Add(new CliVcpCodeInfo
|
||||
{
|
||||
Code = code.FormattedCode,
|
||||
Name = code.Name,
|
||||
Continuous = code.IsContinuous,
|
||||
DiscreteValues = discreteValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (new CliCapabilitiesResult
|
||||
{
|
||||
// Transport lives in the dedicated top-level CommunicationMethod below, so leave
|
||||
// Method off the monitor ref (it is omitted from JSON) to avoid emitting the same
|
||||
// value twice in the capabilities envelope.
|
||||
Monitor = new CliMonitorRef
|
||||
{
|
||||
Number = selected!.MonitorNumber,
|
||||
Id = selected!.Id,
|
||||
Name = selected!.Name,
|
||||
},
|
||||
CommunicationMethod = selected!.CommunicationMethod,
|
||||
RawCapabilities = selected!.CapabilitiesRaw,
|
||||
Model = caps?.Model,
|
||||
MccsVersion = caps?.MccsVersion,
|
||||
VcpCodes = vcpCodes,
|
||||
}, null);
|
||||
}
|
||||
|
||||
// ─── Internal helpers (visible for testing) ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Drops monitors the user hid in PowerDisplay settings.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<Monitor> ExcludeHidden(
|
||||
IReadOnlyList<Monitor> monitors,
|
||||
IReadOnlySet<string> hiddenIds)
|
||||
{
|
||||
if (hiddenIds.Count == 0)
|
||||
{
|
||||
return monitors;
|
||||
}
|
||||
|
||||
var kept = new List<Monitor>(monitors.Count);
|
||||
foreach (var m in monitors)
|
||||
{
|
||||
if (!hiddenIds.Contains(m.Id))
|
||||
{
|
||||
kept.Add(m);
|
||||
}
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the target monitor from the already-filtered list using CLI selector semantics.
|
||||
/// <list type="bullet">
|
||||
/// <item>No selector → <c>SelectorMissing</c> error.</item>
|
||||
/// <item>Both selectors → id wins (the CLI surfaces the "-n ignored" note locally).</item>
|
||||
/// <item>Not found → <c>MonitorNotFound</c> error.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static (Monitor? Monitor, CliError? Error) ResolveMonitor(
|
||||
IReadOnlyList<Monitor> monitors,
|
||||
int? monitorNumber,
|
||||
string? monitorId)
|
||||
{
|
||||
var hasNumber = monitorNumber.HasValue;
|
||||
var hasId = !string.IsNullOrEmpty(monitorId);
|
||||
|
||||
if (!hasNumber && !hasId)
|
||||
{
|
||||
return (null, new CliError
|
||||
{
|
||||
Code = CliErrorCodes.SelectorMissing,
|
||||
MessageId = CliMessageIds.SelectorMissing,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasId)
|
||||
{
|
||||
for (int i = 0; i < monitors.Count; i++)
|
||||
{
|
||||
if (string.Equals(monitors[i].Id, monitorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (monitors[i], null);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, new CliError
|
||||
{
|
||||
Code = CliErrorCodes.MonitorNotFound,
|
||||
MessageId = CliMessageIds.MonitorNotFoundId,
|
||||
Value = monitorId,
|
||||
});
|
||||
}
|
||||
|
||||
var number = monitorNumber!.GetValueOrDefault();
|
||||
for (int i = 0; i < monitors.Count; i++)
|
||||
{
|
||||
if (monitors[i].MonitorNumber == number)
|
||||
{
|
||||
return (monitors[i], null);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, new CliError
|
||||
{
|
||||
Code = CliErrorCodes.MonitorNotFound,
|
||||
MessageId = CliMessageIds.MonitorNotFoundNumber,
|
||||
Value = number.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Builds the compact monitor reference embedded in every response. Shared with <see cref="SetCommandExecutor"/>.</summary>
|
||||
internal static CliMonitorRef ToRef(Monitor m) => new()
|
||||
{
|
||||
Number = m.MonitorNumber,
|
||||
Id = m.Id,
|
||||
Name = m.Name,
|
||||
Method = m.CommunicationMethod,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-monitor <c>get</c> entry. Returns null and sets <paramref name="error"/> when the
|
||||
/// setting filter names an unknown setting.
|
||||
/// </summary>
|
||||
private static CliGetMonitorEntry? BuildGetEntry(
|
||||
Monitor monitor,
|
||||
CliMonitorRef monitorRef,
|
||||
string? settingFilter,
|
||||
IReadOnlyList<CustomVcpValueMapping>? customMappings,
|
||||
out CliError? error)
|
||||
{
|
||||
if (TryGetUnknownSettingError(settingFilter, out var normalizedFilter, out error))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IEnumerable<string> settingNames = normalizedFilter is null
|
||||
? CliSettingNames.All
|
||||
: new[] { normalizedFilter };
|
||||
|
||||
var results = new List<CliSettingValue>();
|
||||
foreach (var name in settingNames)
|
||||
{
|
||||
results.Add(BuildSettingValue(monitor, name, customMappings)!);
|
||||
}
|
||||
|
||||
return new CliGetMonitorEntry
|
||||
{
|
||||
Monitor = monitorRef,
|
||||
Settings = results,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the optional <c>--setting</c> filter against <see cref="CliSettingNames.All"/>.
|
||||
/// Returns <c>true</c> with a populated error when the filter names an unknown setting.
|
||||
/// The error echoes the user's original input verbatim, not the lower-cased lookup key.
|
||||
/// </summary>
|
||||
private static bool TryGetUnknownSettingError(string? settingFilter, out string? normalized, out CliError? error)
|
||||
{
|
||||
error = null;
|
||||
normalized = settingFilter?.ToLowerInvariant();
|
||||
if (settingFilter is null || Array.IndexOf(CliSettingNames.All, normalized) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
error = new CliError
|
||||
{
|
||||
Code = CliErrorCodes.ArgumentError,
|
||||
MessageId = CliMessageIds.UnknownSetting,
|
||||
Value = settingFilter,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects one setting value.
|
||||
/// The value is reported only when the monitor both supports it and discovery
|
||||
/// actually read it (<see cref="Monitor.ReadValues"/>) — a default/stale field is
|
||||
/// never passed off as a live reading.
|
||||
/// </summary>
|
||||
private static CliSettingValue? BuildSettingValue(Monitor monitor, string settingName, IReadOnlyList<CustomVcpValueMapping>? customMappings)
|
||||
{
|
||||
// Orientation is GDI-based (not a VCP setting), so it is not in the catalog. The raw value is
|
||||
// the orientation index; Reading formats it via OrientationDegrees only when it was actually
|
||||
// read.
|
||||
if (settingName == CliSettingNames.Orientation)
|
||||
{
|
||||
return Reading(
|
||||
CliSettingNames.Orientation,
|
||||
!string.IsNullOrEmpty(monitor.GdiDeviceName),
|
||||
monitor.ReadValues.HasFlag(MonitorReadFlags.Orientation),
|
||||
monitor.Orientation,
|
||||
OrientationDegrees);
|
||||
}
|
||||
|
||||
var setting = CliSettingCatalog.TryGet(settingName);
|
||||
if (setting is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Func<int, string> format = setting.Kind == CliSettingKind.Continuous
|
||||
? v => v + "%"
|
||||
: v => FormatDiscrete(setting.VcpCode, v, customMappings, monitor.Id);
|
||||
|
||||
return Reading(
|
||||
setting.Name,
|
||||
setting.Supports(monitor),
|
||||
monitor.ReadValues.HasFlag(setting.ReadFlag),
|
||||
setting.Current(monitor),
|
||||
format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects one setting, gating the value on supported && read.
|
||||
/// </summary>
|
||||
private static CliSettingValue Reading(string name, bool supported, bool read, int raw, Func<int, string> format)
|
||||
{
|
||||
var known = supported && read;
|
||||
return new CliSettingValue
|
||||
{
|
||||
Setting = name,
|
||||
Supported = supported,
|
||||
Display = known ? format(raw) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a discrete VCP value as "Name (0xNN)" or "0xNN" when the name is unknown.
|
||||
/// </summary>
|
||||
internal static string FormatDiscrete(byte vcpCode, int value, IReadOnlyList<CustomVcpValueMapping>? customMappings = null, string monitorId = "")
|
||||
{
|
||||
var name = VcpNames.GetValueName(vcpCode, value, customMappings, monitorId);
|
||||
return name is null
|
||||
? $"0x{value:X2}"
|
||||
: $"{name} (0x{value:X2})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a CLI discrete setting name to its VCP code, or null when the name is not one of the
|
||||
/// three discrete VCP settings (color-temperature 0x14, input-source 0x60, power-state 0xD6).
|
||||
/// </summary>
|
||||
internal static byte? VcpCodeForDiscreteSetting(string setting)
|
||||
{
|
||||
var descriptor = CliSettingCatalog.TryGet(setting.ToLowerInvariant());
|
||||
return descriptor is { Kind: CliSettingKind.Discrete } ? descriptor.VcpCode : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the human-readable orientation string for a GDI orientation index (0–3).
|
||||
/// </summary>
|
||||
internal static string OrientationDegrees(int index) => index switch
|
||||
{
|
||||
0 => "0°",
|
||||
1 => "90°",
|
||||
2 => "180°",
|
||||
3 => "270°",
|
||||
_ => $"index {index}",
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user