mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 16:39:14 +02:00
Compare commits
40 Commits
dev/crutka
...
yuleng/pd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a00f5fdc | ||
|
|
8475d55b8a | ||
|
|
d6319516d0 | ||
|
|
3e3b3df23c | ||
|
|
cf9034d33e | ||
|
|
53737cbe31 | ||
|
|
bf6ff579d3 | ||
|
|
0afe525f31 | ||
|
|
a43fb12d6f | ||
|
|
bc56443443 | ||
|
|
57d9c9b011 | ||
|
|
3298625b67 | ||
|
|
ae9f241ef1 | ||
|
|
67a9fa2d13 | ||
|
|
1cfc923bdb | ||
|
|
2dd802f367 | ||
|
|
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,6 +1,6 @@
|
||||
---
|
||||
name: wpf-to-winui3-migration
|
||||
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
|
||||
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
|
||||
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",
|
||||
|
||||
|
||||
@@ -48,26 +48,6 @@
|
||||
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Restore" Properties="RestoreInProgress=true" BuildInParallel="false" />
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
The Microsoft.Web.WebView2 package's managed .targets unconditionally references the WPF
|
||||
wrapper (Microsoft.Web.WebView2.Wpf.dll) for every non-WinRT .NET project. That wrapper
|
||||
depends on WPF's WindowsBase, which only ships in the WPF profile of the WindowsDesktop
|
||||
reference pack. WinForms-only or plain projects therefore resolve WindowsBase to the
|
||||
4.0.0.0 facade from Microsoft.NETCore.App, producing an MSB3277 conflict against the
|
||||
wrapper's 5.0.0.0 reference. A project that doesn't enable WPF can't use the WPF WebView2
|
||||
control anyway, so drop that unused reference before RAR runs (WPF projects keep it).
|
||||
WinUI/WinAppSDK projects use the CsWinRT projection and never get this reference, so this
|
||||
is a no-op for them.
|
||||
-->
|
||||
<Target
|
||||
Name="RemoveUnusedWebView2WpfReference"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(UseWPF)' != 'true'">
|
||||
<ItemGroup>
|
||||
<Reference Remove="@(Reference)" Condition="'%(Reference.Filename)' == 'Microsoft.Web.WebView2.Wpf'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup Condition="'$(IgnoreExperimentalWarnings)' == 'true'">
|
||||
<NoWarn>$(NoWarn);CS8305;SA1500;CA1852</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
|
||||
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
|
||||
|
||||
@@ -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/`
|
||||
@@ -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?>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
|
||||
</packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -39,6 +39,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
|
||||
</packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -171,6 +171,7 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
|
||||
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,230,18
|
||||
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
|
||||
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
|
||||
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10
|
||||
@@ -182,8 +183,6 @@ BEGIN
|
||||
LTEXT "4.0",IDC_STATIC,190,136,12,8
|
||||
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
|
||||
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18
|
||||
END
|
||||
|
||||
DRAW DIALOGEX 0, 0, 260, 228
|
||||
@@ -315,26 +314,31 @@ BEGIN
|
||||
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
|
||||
END
|
||||
|
||||
SNIP DIALOGEX 0, 0, 260, 80
|
||||
SNIP DIALOGEX 0, 0, 272, 105
|
||||
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
|
||||
FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.",IDC_STATIC,7,7,230,19
|
||||
LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8
|
||||
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,32,80,12
|
||||
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,50,230,10
|
||||
LTEXT "Text Toggle:",IDC_STATIC,7,65,55,8
|
||||
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,63,80,12
|
||||
LTEXT "Copy a region of the screen to the clipboard, or save it to a file using the save shortcut.",IDC_STATIC,7,7,230,18
|
||||
RTEXT "Snip Toggle:",IDC_STATIC,22,33,45,8
|
||||
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,32,80,12
|
||||
RTEXT "Snip Save Toggle:",IDC_STATIC,7,49,60,8
|
||||
CONTROL "",IDC_SNIP_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,48,80,12
|
||||
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,66,230,10
|
||||
RTEXT "Text Toggle:",IDC_STATIC,12,82,55,8
|
||||
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,81,80,12
|
||||
END
|
||||
|
||||
PANORAMA DIALOGEX 0, 0, 260, 105
|
||||
PANORAMA DIALOGEX 0, 0, 260, 140
|
||||
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
|
||||
FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas. Press the hotkey again or with Shift to save to a file.",IDC_STATIC,7,7,245,33
|
||||
LTEXT "Panorama Toggle:",IDC_STATIC,7,74,63,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,72,80,12
|
||||
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,41,245,30
|
||||
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas.",IDC_STATIC,7,7,245,30
|
||||
LTEXT "Press the panorama toggle again to copy to the clipboard, or use the save shortcut to save to a file.",IDC_STATIC,7,39,245,18
|
||||
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,62,245,30
|
||||
LTEXT "Panorama Toggle:",IDC_STATIC,7,95,80,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,93,80,12
|
||||
LTEXT "Panorama Save Toggle:",IDC_STATIC,7,111,80,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,109,80,12
|
||||
END
|
||||
|
||||
DEMOTYPE DIALOGEX 0, 0, 260, 249
|
||||
@@ -456,7 +460,9 @@ BEGIN
|
||||
"SNIP", DIALOG
|
||||
BEGIN
|
||||
LEFTMARGIN, 7
|
||||
RIGHTMARGIN, 265
|
||||
TOPMARGIN, 7
|
||||
BOTTOMMARGIN, 98
|
||||
END
|
||||
|
||||
"PANORAMA", DIALOG
|
||||
|
||||
@@ -17,7 +17,9 @@ DWORD g_BreakToggleKey = ((HOTKEYF_CONTROL) << 8)| '3';
|
||||
DWORD g_DemoTypeToggleKey = ((HOTKEYF_CONTROL) << 8) | '7';
|
||||
DWORD g_RecordToggleKey = ((HOTKEYF_CONTROL) << 8) | '5';
|
||||
DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
|
||||
DWORD g_SnipSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '6';
|
||||
DWORD g_SnipPanoramaToggleKey = ((HOTKEYF_CONTROL) << 8) | '8';
|
||||
DWORD g_SnipPanoramaSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '8';
|
||||
DWORD g_SnipOcrToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_ALT) << 8) | '6';
|
||||
|
||||
DWORD g_ShowExpiredTime = 1;
|
||||
@@ -80,7 +82,9 @@ REG_SETTING RegSettings[] = {
|
||||
{ L"DrawToggleKey", SETTING_TYPE_DWORD, 0, &g_DrawToggleKey, static_cast<DOUBLE>(g_DrawToggleKey) },
|
||||
{ L"RecordToggleKey", SETTING_TYPE_DWORD, 0, &g_RecordToggleKey, static_cast<DOUBLE>(g_RecordToggleKey) },
|
||||
{ L"SnipToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipToggleKey, static_cast<DOUBLE>(g_SnipToggleKey) },
|
||||
{ L"SnipSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipSaveToggleKey, static_cast<DOUBLE>(g_SnipSaveToggleKey) },
|
||||
{ L"SnipPanoramaToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaToggleKey, static_cast<DOUBLE>(g_SnipPanoramaToggleKey) },
|
||||
{ L"SnipPanoramaSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaSaveToggleKey, static_cast<DOUBLE>(g_SnipPanoramaSaveToggleKey) },
|
||||
{ L"SnipOcrToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipOcrToggleKey, static_cast<DOUBLE>(g_SnipOcrToggleKey) },
|
||||
{ L"PenColor", SETTING_TYPE_DWORD, 0, &g_PenColor, static_cast<DOUBLE>(g_PenColor) },
|
||||
{ L"PenWidth", SETTING_TYPE_DWORD, 0, &g_RootPenWidth, static_cast<DOUBLE>(g_RootPenWidth) },
|
||||
|
||||
@@ -174,6 +174,8 @@ DWORD g_RecordToggleMod;
|
||||
DWORD g_SnipToggleMod;
|
||||
DWORD g_SnipPanoramaToggleMod;
|
||||
DWORD g_SnipOcrToggleMod;
|
||||
DWORD g_SnipSaveToggleMod;
|
||||
DWORD g_SnipPanoramaSaveToggleMod;
|
||||
|
||||
BOOLEAN g_ZoomOnLiveZoom = FALSE;
|
||||
DWORD g_PenWidth = PEN_WIDTH;
|
||||
@@ -212,7 +214,10 @@ BOOL g_RecordToggle = FALSE;
|
||||
BOOL g_RecordCropping = FALSE;
|
||||
SelectRectangle g_SelectRectangle;
|
||||
WebcamPreviewWindow g_WebcamPreview;
|
||||
// The full path of the last saved recording file.
|
||||
std::wstring g_RecordingSaveLocation;
|
||||
// The last user-chosen recording filename. Used to construct unique recording filenames.
|
||||
std::wstring g_RecordingSaveBaseFilename;
|
||||
std::wstring g_ScreenshotSaveLocation;
|
||||
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
|
||||
std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr;
|
||||
@@ -3582,12 +3587,16 @@ void RegisterAllHotkeys(HWND hWnd)
|
||||
}
|
||||
if (g_SnipToggleKey) {
|
||||
registerHotkey( SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF );
|
||||
registerHotkey( SNIP_SAVE_HOTKEY, ( g_SnipToggleMod ^ MOD_SHIFT ), g_SnipToggleKey & 0xFF );
|
||||
}
|
||||
if( g_SnipPanoramaToggleKey &&
|
||||
if (g_SnipSaveToggleKey) {
|
||||
registerHotkey( SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF);
|
||||
}
|
||||
if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) ) {
|
||||
registerHotkey( SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
|
||||
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
|
||||
}
|
||||
if (g_SnipPanoramaSaveToggleKey) {
|
||||
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF );
|
||||
}
|
||||
if (g_SnipOcrToggleKey) {
|
||||
registerHotkey( SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF );
|
||||
@@ -4816,6 +4825,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
TCHAR text[32];
|
||||
DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey, newSnipPanoramaToggleKey, newSnipOcrToggleKey;
|
||||
DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod, newSnipPanoramaToggleMod, newSnipOcrToggleMod;
|
||||
DWORD newSnipSaveToggleKey, newSnipSaveToggleMod;
|
||||
DWORD newSnipPanoramaSaveToggleKey, newSnipPanoramaSaveToggleMod;
|
||||
DWORD newLiveZoomToggleKey, newLiveZoomToggleMod;
|
||||
static std::vector<std::pair<std::wstring, std::wstring>> microphones;
|
||||
|
||||
@@ -5050,7 +5061,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 );
|
||||
if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 );
|
||||
if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 );
|
||||
if( g_SnipSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipSaveToggleKey, 0 );
|
||||
if( g_SnipPanoramaToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaToggleKey, 0 );
|
||||
if( g_SnipPanoramaSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaSaveToggleKey, 0 );
|
||||
if( g_SnipOcrToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_SETHOTKEY, g_SnipOcrToggleKey, 0 );
|
||||
CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON,
|
||||
g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED );
|
||||
@@ -5512,7 +5525,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
newDemoTypeToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_GETHOTKEY, 0, 0 ));
|
||||
newRecordToggleKey = static_cast<DWORD>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_GETHOTKEY, 0, 0));
|
||||
newSnipToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipPanoramaToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipPanoramaSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipOcrToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
|
||||
newToggleMod = GetKeyMod( newToggleKey );
|
||||
@@ -5522,7 +5537,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
newDemoTypeToggleMod = GetKeyMod( newDemoTypeToggleKey );
|
||||
newRecordToggleMod = GetKeyMod(newRecordToggleKey);
|
||||
newSnipToggleMod = GetKeyMod( newSnipToggleKey );
|
||||
newSnipSaveToggleMod = GetKeyMod( newSnipSaveToggleKey );
|
||||
newSnipPanoramaToggleMod = GetKeyMod( newSnipPanoramaToggleKey );
|
||||
newSnipPanoramaSaveToggleMod = GetKeyMod( newSnipPanoramaSaveToggleKey );
|
||||
newSnipOcrToggleMod = GetKeyMod( newSnipOcrToggleKey );
|
||||
|
||||
g_SliderZoomLevel = static_cast<int>(SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_GETPOS, 0, 0 ));
|
||||
@@ -5591,25 +5608,41 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
|
||||
}
|
||||
else if (newSnipToggleKey &&
|
||||
(!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipSaveToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, newSnipSaveToggleMod, newSnipSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipPanoramaToggleKey &&
|
||||
(newSnipPanoramaToggleKey != newSnipToggleKey || newSnipPanoramaToggleMod != newSnipToggleMod) &&
|
||||
(!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, ( newSnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipPanoramaSaveToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, newSnipPanoramaSaveToggleMod | MOD_NOREPEAT, newSnipPanoramaSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipOcrToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_OCR_HOTKEY, newSnipOcrToggleMod, newSnipOcrToggleKey & 0xFF)) {
|
||||
@@ -5645,8 +5678,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
g_RecordToggleMod = newRecordToggleMod;
|
||||
g_SnipToggleKey = newSnipToggleKey;
|
||||
g_SnipToggleMod = newSnipToggleMod;
|
||||
g_SnipSaveToggleKey = newSnipSaveToggleKey;
|
||||
g_SnipSaveToggleMod = newSnipSaveToggleMod;
|
||||
g_SnipPanoramaToggleKey = newSnipPanoramaToggleKey;
|
||||
g_SnipPanoramaToggleMod = newSnipPanoramaToggleMod;
|
||||
g_SnipPanoramaSaveToggleKey = newSnipPanoramaSaveToggleKey;
|
||||
g_SnipPanoramaSaveToggleMod = newSnipPanoramaSaveToggleMod;
|
||||
g_SnipOcrToggleKey = newSnipOcrToggleKey;
|
||||
g_SnipOcrToggleMod = newSnipOcrToggleMod;
|
||||
reg.WriteRegSettings( RegSettings );
|
||||
@@ -6737,6 +6774,45 @@ void StopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
// GetTimestampSuffix
|
||||
//
|
||||
// Returns a timestamp string for disambiguating filenames.
|
||||
// Format: " YYYY-MM-DD HHMMSS", e.g." 2025-11-02 143000".
|
||||
//
|
||||
// Used as a suffix for the default recording filename. Ensures
|
||||
// chronological name sorting in Explorer.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
static std::wstring GetTimestampSuffix()
|
||||
{
|
||||
auto const now = std::chrono::system_clock::now();
|
||||
auto const in_time_t = std::chrono::system_clock::to_time_t( now );
|
||||
|
||||
std::tm buf{};
|
||||
localtime_s( &buf, &in_time_t );
|
||||
|
||||
std::wstringstream ss;
|
||||
ss << L" " << std::put_time( &buf, L"%Y-%m-%d %H%M%S" );
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
// IsDefaultRecordingFilename
|
||||
//
|
||||
// Determines if the provided filename matches the default recording name.
|
||||
// Case-insensitive comparison.
|
||||
//
|
||||
// Returns:
|
||||
// true if filename is the default; otherwise false.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
static bool IsDefaultRecordingFilename(const std::wstring& filename)
|
||||
{
|
||||
return CompareStringOrdinal( DEFAULT_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL
|
||||
|| CompareStringOrdinal( DEFAULT_GIF_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
//
|
||||
@@ -6791,19 +6867,70 @@ std::wstring GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t*
|
||||
//
|
||||
// GetUniqueRecordingFilename
|
||||
//
|
||||
// Gets a unique file name for recording saves, using the " (N)" suffix
|
||||
// approach so that the user can hit OK without worrying about overwriting
|
||||
// if they are making multiple recordings in one session or don't want to
|
||||
// always see an overwrite dialog or stop to clean up files.
|
||||
// Generates a unique filename to be suggested in the "Save As" recording
|
||||
// dialog, based on the user's last chosen filename and save location.
|
||||
// This allows the user to quickly save a recording without worrying about
|
||||
// manual renaming to prevent overwriting earlier recordings.
|
||||
//
|
||||
// There are two distinct behaviors based on the last used filename:
|
||||
//
|
||||
// 1. For the default filename ("Recording.mp4"):
|
||||
// Generates a more descriptive name by appending a timestamp, e.g.
|
||||
// "Recording 2025-11-03 143015.mp4". This ensures chronological sorting
|
||||
// in Explorer when ordered by name and is consistent with other tools.
|
||||
//
|
||||
// 2. For custom filenames (e.g. "Presentation.mp4"):
|
||||
// Appends a numeric suffix if the file already exists, e.g.
|
||||
// "Presentation (1).mp4", "Presentation (2).mp4", etc.
|
||||
//
|
||||
// Returns:
|
||||
// A unique filename (without folder path).
|
||||
//
|
||||
// Relies upon the global state of `g_RecordingSaveLocation` and
|
||||
// `g_RecordingSaveBaseFilename`.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
auto GetUniqueRecordingFilename()
|
||||
static auto GetUniqueRecordingFilename()
|
||||
{
|
||||
const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF)
|
||||
? DEFAULT_GIF_RECORDING_FILE
|
||||
: DEFAULT_RECORDING_FILE;
|
||||
|
||||
return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos);
|
||||
// Without a remembered filename, suggest the default name for the current format.
|
||||
std::wstring baseFilename = g_RecordingSaveBaseFilename.empty()
|
||||
? std::wstring( defaultFile )
|
||||
: g_RecordingSaveBaseFilename;
|
||||
|
||||
std::filesystem::path basePath{ baseFilename };
|
||||
|
||||
// For the default filename, append a timestamp so successive default saves stay
|
||||
// unique and sort chronologically in Explorer.
|
||||
if ( IsDefaultRecordingFilename( basePath.filename().wstring() ) )
|
||||
{
|
||||
return basePath.stem().wstring() + GetTimestampSuffix() + basePath.extension().wstring();
|
||||
}
|
||||
|
||||
// For custom filenames, append a numeric suffix to avoid collisions.
|
||||
std::filesystem::path directory;
|
||||
if ( !g_RecordingSaveLocation.empty() )
|
||||
directory = std::filesystem::path( g_RecordingSaveLocation ).parent_path();
|
||||
if ( directory.empty() )
|
||||
{
|
||||
wil::unique_cotaskmem_string folderPath;
|
||||
if ( SUCCEEDED( SHGetKnownFolderPath( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, folderPath.put() ) ) )
|
||||
directory = folderPath.get();
|
||||
}
|
||||
|
||||
std::wstring baseStem = basePath.stem().wstring();
|
||||
std::wstring baseExtension = basePath.extension().wstring();
|
||||
|
||||
std::filesystem::path testPath = directory / ( baseStem + baseExtension );
|
||||
for ( int index = 1; std::filesystem::exists( testPath ); index++ )
|
||||
{
|
||||
testPath = directory / ( baseStem + L" (" + std::to_wstring( index ) + L')' + baseExtension );
|
||||
}
|
||||
|
||||
return testPath.filename().wstring();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
@@ -6835,7 +6962,7 @@ auto GetUniqueScreenshotFilename()
|
||||
//
|
||||
// StartRecordingAsync
|
||||
//
|
||||
// Starts the screen recording.
|
||||
// Initiates screen recording and handles the save dialog workflow.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try
|
||||
@@ -7080,8 +7207,30 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
|
||||
if (!finalPath.empty())
|
||||
{
|
||||
auto path = std::filesystem::path(finalPath);
|
||||
|
||||
// Remember the user's chosen filename and apply a timestamp to default
|
||||
// names so successive saves stay unique and sort chronologically.
|
||||
std::wstring filename = path.filename().wstring();
|
||||
std::wstring finalFilename = filename;
|
||||
if ( IsDefaultRecordingFilename( filename ) )
|
||||
{
|
||||
// The user accepted or re-typed the default filename. Remember it so the
|
||||
// next suggestion also uses a timestamp, and append one to this save.
|
||||
g_RecordingSaveBaseFilename = filename;
|
||||
finalFilename = path.stem().wstring() + GetTimestampSuffix() + path.extension().wstring();
|
||||
}
|
||||
else if ( CompareStringOrdinal( suggestedName.c_str(), -1, filename.c_str(), -1, TRUE ) != CSTR_EQUAL )
|
||||
{
|
||||
// The user chose their own filename instead of the suggested one. Remember
|
||||
// it so future suggestions use numeric suffixes based on this name.
|
||||
g_RecordingSaveBaseFilename = filename;
|
||||
}
|
||||
|
||||
// The path actually written to disk (with any timestamp applied).
|
||||
std::wstring savedPath = ( path.parent_path() / finalFilename ).wstring();
|
||||
|
||||
winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) };
|
||||
destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting);
|
||||
destFile = co_await folder.CreateFileAsync(finalFilename.c_str(), winrt::CreationCollisionOption::ReplaceExisting);
|
||||
|
||||
// If user trimmed, use the trimmed file
|
||||
winrt::StorageFile sourceFile = file;
|
||||
@@ -7099,8 +7248,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
|
||||
try { co_await file.DeleteAsync(); } catch (...) {}
|
||||
}
|
||||
|
||||
// Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
|
||||
g_RecordingSaveLocation = finalPath;
|
||||
// Use savedPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
|
||||
g_RecordingSaveLocation = savedPath;
|
||||
// Update the registry buffer and save to persist across app restarts
|
||||
wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE);
|
||||
reg.WriteRegSettings(RegSettings);
|
||||
@@ -7600,7 +7749,9 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_BreakToggleMod = GetKeyMod( g_BreakToggleKey );
|
||||
g_DemoTypeToggleMod = GetKeyMod( g_DemoTypeToggleKey );
|
||||
g_SnipToggleMod = GetKeyMod( g_SnipToggleKey );
|
||||
g_SnipSaveToggleMod = GetKeyMod( g_SnipSaveToggleKey );
|
||||
g_SnipPanoramaToggleMod = GetKeyMod( g_SnipPanoramaToggleKey );
|
||||
g_SnipPanoramaSaveToggleMod = GetKeyMod( g_SnipPanoramaSaveToggleKey );
|
||||
g_SnipOcrToggleMod = GetKeyMod( g_SnipOcrToggleKey );
|
||||
g_RecordToggleMod = GetKeyMod( g_RecordToggleKey );
|
||||
|
||||
@@ -7651,23 +7802,37 @@ LRESULT APIENTRY MainWndProc(
|
||||
|
||||
}
|
||||
else if (g_SnipToggleKey &&
|
||||
(!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipSaveToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) &&
|
||||
(!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipPanoramaSaveToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipOcrToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF)) {
|
||||
@@ -10254,7 +10419,9 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_BreakToggleMod = GetKeyMod(g_BreakToggleKey);
|
||||
g_DemoTypeToggleMod = GetKeyMod(g_DemoTypeToggleKey);
|
||||
g_SnipToggleMod = GetKeyMod(g_SnipToggleKey);
|
||||
g_SnipSaveToggleMod = GetKeyMod(g_SnipSaveToggleKey);
|
||||
g_SnipPanoramaToggleMod = GetKeyMod(g_SnipPanoramaToggleKey);
|
||||
g_SnipPanoramaSaveToggleMod = GetKeyMod(g_SnipPanoramaSaveToggleKey);
|
||||
g_SnipOcrToggleMod = GetKeyMod(g_SnipOcrToggleKey);
|
||||
g_RecordToggleMod = GetKeyMod(g_RecordToggleKey);
|
||||
BOOL showOptions = FALSE;
|
||||
@@ -10317,8 +10484,7 @@ LRESULT APIENTRY MainWndProc(
|
||||
}
|
||||
if (g_SnipToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))
|
||||
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
@@ -10327,11 +10493,21 @@ LRESULT APIENTRY MainWndProc(
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipSaveToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.", APPNAME, MB_ICONERROR);
|
||||
}
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod))
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
@@ -10340,6 +10516,17 @@ LRESULT APIENTRY MainWndProc(
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipPanoramaSaveToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.", APPNAME, MB_ICONERROR);
|
||||
}
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipOcrToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF))
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <regex>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
#define IDC_WEBCAM_BRIGHTNESS_LABEL 1131
|
||||
#define IDC_WEBCAM_BRIGHTNESS_SLIDER 1132
|
||||
#define IDC_NOISE_CANCELLATION 1133
|
||||
#define IDC_SNIP_SAVE_HOTKEY 1134
|
||||
#define IDC_SNIP_PANORAMA_SAVE_HOTKEY 1135
|
||||
#define IDC_SAVE 40002
|
||||
#define IDC_COPY 40004
|
||||
#define IDC_RECORD 40006
|
||||
@@ -151,8 +153,8 @@
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 120
|
||||
#define _APS_NEXT_COMMAND_VALUE 40015
|
||||
#define _APS_NEXT_CONTROL_VALUE 1134
|
||||
#define _APS_NEXT_COMMAND_VALUE 40012
|
||||
#define _APS_NEXT_CONTROL_VALUE 1136
|
||||
#define _APS_NEXT_SYMED_VALUE 101
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -70,8 +70,10 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation
|
||||
{ L"DrawToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"RecordToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"SnipToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"SnipSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"SnipOcrToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"SnipPanoramaToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"SnipPanoramaSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"BreakTimerKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"DemoTypeToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
|
||||
{ L"PenColor", SPECIAL_SEMANTICS_COLOR },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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 Microsoft.CmdPal.Common.Helpers;
|
||||
|
||||
public static class ShellArgumentBuilder
|
||||
{
|
||||
public static string BuildArguments(params string[] arguments)
|
||||
{
|
||||
if (arguments.Length <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
AppendArgument(stringBuilder, argument);
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendArgument(StringBuilder stringBuilder, string argument)
|
||||
{
|
||||
if (stringBuilder.Length > 0)
|
||||
{
|
||||
stringBuilder.Append(' ');
|
||||
}
|
||||
|
||||
if (argument.Length == 0 || ShouldBeQuoted(argument))
|
||||
{
|
||||
stringBuilder.Append('"');
|
||||
var index = 0;
|
||||
while (index < argument.Length)
|
||||
{
|
||||
var c = argument[index++];
|
||||
if (c == '\\')
|
||||
{
|
||||
var numBackSlash = 1;
|
||||
while (index < argument.Length && argument[index] == '\\')
|
||||
{
|
||||
index++;
|
||||
numBackSlash++;
|
||||
}
|
||||
|
||||
if (index == argument.Length)
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash * 2);
|
||||
}
|
||||
else if (argument[index] == '"')
|
||||
{
|
||||
stringBuilder.Append('\\', (numBackSlash * 2) + 1);
|
||||
stringBuilder.Append('"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
stringBuilder.Append('\\');
|
||||
stringBuilder.Append('"');
|
||||
continue;
|
||||
}
|
||||
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
|
||||
stringBuilder.Append('"');
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(argument);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldBeQuoted(string argument)
|
||||
{
|
||||
foreach (var c in argument)
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == '"')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ internal sealed partial class HttpCachingClient : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_cacheHandler.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
|
||||
@@ -146,13 +146,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
// The all apps page will kick off a BG thread to start loading apps.
|
||||
// We just want to know when it is done.
|
||||
var allApps = AllAppsCommandProvider.Page;
|
||||
allApps.PropChanged += (s, p) =>
|
||||
{
|
||||
if (p.PropertyName == nameof(allApps.IsLoading))
|
||||
{
|
||||
IsLoading = ActuallyLoading();
|
||||
}
|
||||
};
|
||||
allApps.PropChanged += AllApps_PropChanged;
|
||||
|
||||
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
|
||||
@@ -172,6 +166,14 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
private void AllApps_PropChanged(object? sender, IPropChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AllAppsCommandProvider.Page.IsLoading))
|
||||
{
|
||||
IsLoading = ActuallyLoading();
|
||||
}
|
||||
}
|
||||
|
||||
private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
_defaultViewDirty = true;
|
||||
@@ -782,6 +784,8 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
|
||||
_tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged;
|
||||
|
||||
AllAppsCommandProvider.Page.PropChanged -= AllApps_PropChanged;
|
||||
|
||||
if (_settingsService is not null)
|
||||
{
|
||||
_settingsService.SettingsChanged -= SettingsChangedHandler;
|
||||
|
||||
@@ -541,6 +541,9 @@ public partial class WinRTExtensionService : IExtensionService, IDisposable
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_catalog.PackageInstalling -= Catalog_PackageInstalling;
|
||||
_catalog.PackageUninstalling -= Catalog_PackageUninstalling;
|
||||
_catalog.PackageUpdating -= Catalog_PackageUpdating;
|
||||
_getInstalledExtensionsLock.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ internal sealed partial class BlurImageControl : Control
|
||||
private SpriteVisual? _effectVisual;
|
||||
private CompositionEffectBrush? _effectBrush;
|
||||
private CompositionSurfaceBrush? _imageBrush;
|
||||
private LoadedImageSurface? _lastLoadedSurface;
|
||||
|
||||
public BlurImageControl()
|
||||
{
|
||||
@@ -379,10 +380,20 @@ internal sealed partial class BlurImageControl : Control
|
||||
}
|
||||
|
||||
Logger.LogDebug($"Starting load of BlurImageControl from '{bitmapImage.UriSource}'");
|
||||
|
||||
// Each call to LoadImageAsync creates a new LoadedImageSurface backed by native
|
||||
// composition resources. The old surface becomes unrooted once the brush points at
|
||||
// the new one, so it isn't leaked, but dispose it explicitly so the unmanaged
|
||||
// resources are released deterministically instead of waiting for finalization.
|
||||
var previousSurface = _lastLoadedSurface;
|
||||
|
||||
var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource);
|
||||
_lastLoadedSurface = loadedSurface;
|
||||
loadedSurface.LoadCompleted += OnLoadedSurfaceOnLoadCompleted;
|
||||
SetLoadedSurfaceToBrush(loadedSurface);
|
||||
_effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush);
|
||||
|
||||
previousSurface?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,9 @@ using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
@@ -22,6 +24,7 @@ public sealed partial class ContentFormControl : UserControl
|
||||
// tree. If this gets GC'ed, then it'll revoke our Action handler, and the
|
||||
// form will do seemingly nothing.
|
||||
private RenderedAdaptiveCard? _renderedCard;
|
||||
private AdaptiveCard? _adaptiveCard;
|
||||
|
||||
public ContentFormViewModel? ViewModel { get => _viewModel; set => AttachViewModel(value); }
|
||||
|
||||
@@ -95,9 +98,11 @@ public sealed partial class ContentFormControl : UserControl
|
||||
private void DisplayCard(AdaptiveCardParseResult result)
|
||||
{
|
||||
_renderedCard = _renderer.RenderAdaptiveCard(result.AdaptiveCard);
|
||||
_adaptiveCard = result.AdaptiveCard;
|
||||
ContentGrid.Children.Clear();
|
||||
if (_renderedCard.FrameworkElement is not null)
|
||||
{
|
||||
_renderedCard.FrameworkElement.KeyDown += OnFormKeyDown;
|
||||
ContentGrid.Children.Add(_renderedCard.FrameworkElement);
|
||||
|
||||
// Use the Loaded event to ensure we focus after the card is in the visual tree
|
||||
@@ -114,8 +119,9 @@ public sealed partial class ContentFormControl : UserControl
|
||||
|
||||
private void OnFrameworkElementLayoutUpdated(object? sender, object e)
|
||||
{
|
||||
// Only fix once — unhook after first layout pass
|
||||
if (_renderedCard?.FrameworkElement is FrameworkElement element)
|
||||
// Only fix once — unhook from sender (not _renderedCard, which may have been
|
||||
// reassigned by the time this fires).
|
||||
if (sender is FrameworkElement element)
|
||||
{
|
||||
element.LayoutUpdated -= OnFrameworkElementLayoutUpdated;
|
||||
FixToggleAccessibilityNames(element);
|
||||
@@ -276,6 +282,50 @@ public sealed partial class ContentFormControl : UserControl
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnFormKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
// Snapshot the fields so a subsequent DisplayCard call can't swap the
|
||||
// rendered/parsed card out from under us mid-method. This keeps the
|
||||
// resolved submit action and the gathered inputs from the same card.
|
||||
var renderedCard = _renderedCard;
|
||||
var adaptiveCard = _adaptiveCard;
|
||||
|
||||
if (e.Key != VirtualKey.Enter || renderedCard == null || adaptiveCard == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only submit when Enter is pressed inside a single-line TextBox
|
||||
if (e.OriginalSource is TextBox textBox && !textBox.AcceptsReturn)
|
||||
{
|
||||
// Find the first Submit or Execute action on the card
|
||||
IAdaptiveActionElement? submitAction = null;
|
||||
foreach (var action in adaptiveCard.Actions)
|
||||
{
|
||||
if (action is AdaptiveSubmitAction or AdaptiveExecuteAction)
|
||||
{
|
||||
submitAction = action;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (submitAction != null)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
// Validate (and gather) the inputs before submitting. AsJson() only
|
||||
// returns the values cached by a successful ValidateInputs() call, so
|
||||
// skipping this would submit an empty payload. This mirrors what the
|
||||
// renderer does internally when a submit button is clicked.
|
||||
var inputs = renderedCard.UserInputs;
|
||||
if (inputs.ValidateInputs(submitAction))
|
||||
{
|
||||
ViewModel?.HandleSubmit(submitAction, inputs.AsJson());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Rendered_Action(RenderedAdaptiveCard sender, AdaptiveActionEventArgs args) =>
|
||||
ViewModel?.HandleSubmit(args.Action, args.Inputs.AsJson());
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
App.Current.Services.GetRequiredService<ISettingsService>().SettingsChanged += SettingsChangedHandler;
|
||||
|
||||
// Make sure that we update the acrylic theme when the OS theme changes
|
||||
RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop);
|
||||
RootElement.ActualThemeChanged += RootElement_ActualThemeChanged;
|
||||
|
||||
// Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h
|
||||
NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () =>
|
||||
@@ -222,6 +222,11 @@ public sealed partial class MainWindow : WindowEx,
|
||||
UpdateBackdrop();
|
||||
}
|
||||
|
||||
private void RootElement_ActualThemeChanged(FrameworkElement sender, object args)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(UpdateBackdrop);
|
||||
}
|
||||
|
||||
private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e)
|
||||
{
|
||||
if (e.Key == VirtualKey.GoBack)
|
||||
@@ -1683,6 +1688,9 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
|
||||
App.Current.Services.GetRequiredService<ISettingsService>().SettingsChanged -= SettingsChangedHandler;
|
||||
|
||||
_localKeyboardListener.Dispose();
|
||||
_windowThemeSynchronizer.Dispose();
|
||||
DisposeAcrylic();
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed partial class ExtensionsPage : Page
|
||||
|
||||
private readonly SettingsViewModel? viewModel;
|
||||
private readonly Dictionary<string, WeakReference<SettingsCard>> _vmToCardMap = new();
|
||||
private readonly Dictionary<SettingsCard, ProviderSettingsViewModel> _cardToVmMap = new();
|
||||
|
||||
public ExtensionsPage()
|
||||
{
|
||||
@@ -31,6 +32,23 @@ public sealed partial class ExtensionsPage : Page
|
||||
var themeService = App.Current.Services.GetService<IThemeService>()!;
|
||||
var settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
|
||||
viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
|
||||
|
||||
Unloaded += ExtensionsPage_Unloaded;
|
||||
}
|
||||
|
||||
private void ExtensionsPage_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// ProviderSettingsViewModel subscribes to its CommandProviderWrapper (owned by the
|
||||
// singleton TopLevelCommandManager), so a live VM roots this page through the
|
||||
// PropertyChanged handler below. Drain any VMs still hooked when the page is torn
|
||||
// down; SettingsCard_DataContextChanged only unhooks the ones that get recycled.
|
||||
foreach (var vm in _cardToVmMap.Values)
|
||||
{
|
||||
vm.PropertyChanged -= ProviderViewModel_PropertyChanged;
|
||||
}
|
||||
|
||||
_cardToVmMap.Clear();
|
||||
_vmToCardMap.Clear();
|
||||
}
|
||||
|
||||
private void SettingsCard_Click(object sender, RoutedEventArgs e)
|
||||
@@ -46,16 +64,28 @@ public sealed partial class ExtensionsPage : Page
|
||||
|
||||
private void SettingsCard_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
|
||||
{
|
||||
// Store the card reference keyed by Id (not the VM itself) to avoid leaking VM references
|
||||
if (sender is SettingsCard card && card.DataContext is ProviderSettingsViewModel newVm)
|
||||
if (sender is SettingsCard card)
|
||||
{
|
||||
_vmToCardMap[newVm.Id] = new WeakReference<SettingsCard>(card);
|
||||
newVm.PropertyChanged += ProviderViewModel_PropertyChanged;
|
||||
|
||||
// Immediately update automation name in case DisplayName is already available
|
||||
if (card.Content is ToggleSwitch toggle && !string.IsNullOrEmpty(newVm.DisplayName))
|
||||
// Unsubscribe from the previous ViewModel to prevent handler accumulation
|
||||
// when virtualization recycles items with a new DataContext.
|
||||
if (_cardToVmMap.TryGetValue(card, out var oldVm))
|
||||
{
|
||||
AutomationProperties.SetName(toggle, newVm.DisplayName);
|
||||
oldVm.PropertyChanged -= ProviderViewModel_PropertyChanged;
|
||||
_cardToVmMap.Remove(card);
|
||||
}
|
||||
|
||||
// Store the card reference keyed by Id (not the VM itself) to avoid leaking VM references
|
||||
if (card.DataContext is ProviderSettingsViewModel newVm)
|
||||
{
|
||||
_vmToCardMap[newVm.Id] = new WeakReference<SettingsCard>(card);
|
||||
_cardToVmMap[card] = newVm;
|
||||
newVm.PropertyChanged += ProviderViewModel_PropertyChanged;
|
||||
|
||||
// Immediately update automation name in case DisplayName is already available
|
||||
if (card.Content is ToggleSwitch toggle && !string.IsNullOrEmpty(newVm.DisplayName))
|
||||
{
|
||||
AutomationProperties.SetName(toggle, newVm.DisplayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Microsoft.CmdPal.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Helpers;
|
||||
|
||||
[TestClass]
|
||||
public class ShellArgumentBuilderTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("plain", "plain")]
|
||||
[DataRow("C:\\Program Files\\PowerToys", "\"C:\\Program Files\\PowerToys\"")]
|
||||
[DataRow("say \"hello\"", "\"say \\\"hello\\\"\"")]
|
||||
[DataRow("", "\"\"")]
|
||||
[DataRow("C:\\Program Files\\", "\"C:\\Program Files\\\\\"")]
|
||||
public void BuildArguments_FormatsSingleArgument(string argument, string expected)
|
||||
{
|
||||
var actual = ShellArgumentBuilder.BuildArguments(argument);
|
||||
|
||||
Assert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildArguments_FormatsMultipleArguments()
|
||||
{
|
||||
var actual = ShellArgumentBuilder.BuildArguments("plain", "C:\\Program Files\\PowerToys", "two words");
|
||||
|
||||
Assert.AreEqual("plain \"C:\\Program Files\\PowerToys\" \"two words\"", actual);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
@@ -24,7 +25,7 @@ internal static class CommandLauncher
|
||||
// You can notice the difference with Recycle Bin for example:
|
||||
// - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
// - "::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
return ShellHelpers.OpenInShell("explorer.exe", classification.Target);
|
||||
return ShellHelpers.OpenInShell("explorer.exe", ShellArgumentBuilder.BuildArguments(classification.Target));
|
||||
|
||||
case LaunchMethod.ActivateAppId:
|
||||
return ActivateAppId(classification.Target, classification.Arguments);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<ProjectPriFileName>Microsoft.CmdPal.Ext.Bookmarks.pri</ProjectPriFileName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
@@ -42,98 +42,7 @@ public class ShellListPageHelpers
|
||||
executable = segments[0];
|
||||
if (segments.Length > 1)
|
||||
{
|
||||
arguments = ArgumentBuilder.BuildArguments(segments[1..]);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ArgumentBuilder
|
||||
{
|
||||
internal static string BuildArguments(string[] arguments)
|
||||
{
|
||||
if (arguments.Length <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
AppendArgument(stringBuilder, argument);
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendArgument(StringBuilder stringBuilder, string argument)
|
||||
{
|
||||
if (stringBuilder.Length > 0)
|
||||
{
|
||||
stringBuilder.Append(' ');
|
||||
}
|
||||
|
||||
if (argument.Length == 0 || ShouldBeQuoted(argument))
|
||||
{
|
||||
stringBuilder.Append('\"');
|
||||
var index = 0;
|
||||
while (index < argument.Length)
|
||||
{
|
||||
var c = argument[index++];
|
||||
if (c == '\\')
|
||||
{
|
||||
var numBackSlash = 1;
|
||||
while (index < argument.Length && argument[index] == '\\')
|
||||
{
|
||||
index++;
|
||||
numBackSlash++;
|
||||
}
|
||||
|
||||
if (index == argument.Length)
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash * 2);
|
||||
}
|
||||
else if (argument[index] == '\"')
|
||||
{
|
||||
stringBuilder.Append('\\', (numBackSlash * 2) + 1);
|
||||
stringBuilder.Append('\"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\"')
|
||||
{
|
||||
stringBuilder.Append('\\');
|
||||
stringBuilder.Append('\"');
|
||||
continue;
|
||||
}
|
||||
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
|
||||
stringBuilder.Append('\"');
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(argument);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldBeQuoted(string s)
|
||||
{
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == '\"')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
arguments = ShellArgumentBuilder.BuildArguments(segments[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public partial class ShowFileInFolderCommand : InvokableCommand
|
||||
try
|
||||
{
|
||||
var argument = "/select, \"" + _path + "\"";
|
||||
Process.Start("explorer.exe", argument);
|
||||
using var process = Process.Start("explorer.exe", argument);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
@@ -866,6 +866,14 @@ void FancyZones::UpdateWorkAreas(bool updateWindowPositions) noexcept
|
||||
std::vector<FancyZonesDataTypes::MonitorId> monitors = { FancyZonesDataTypes::MonitorId{ .monitor = nullptr, .deviceId = { .id = ZonedWindowProperties::MultiMonitorName, .instanceId = ZonedWindowProperties::MultiMonitorInstance } } };
|
||||
if (ShouldWorkAreasBeRecreated(monitors, currentVirtualDesktop, m_workAreaConfiguration.GetAllWorkAreas()))
|
||||
{
|
||||
// WindowMouseSnap caches a raw WorkArea* in m_currentWorkArea and the
|
||||
// WorkArea map by reference. WorkAreaConfiguration::Clear() destroys
|
||||
// every unique_ptr<WorkArea> (and hence the inner ZonesOverlay and
|
||||
// its std::mutex). If a drag is in flight, the next MoveSizeUpdate
|
||||
// would dereference that dangling WorkArea* and lock the freed
|
||||
// mutex. Drain the active drag first so subsequent drag messages
|
||||
// hit the snapper's `if (m_windowMouseSnapper)` guard and no-op.
|
||||
MoveSizeEnd();
|
||||
m_workAreaConfiguration.Clear();
|
||||
|
||||
FancyZonesDataTypes::WorkAreaId workAreaId;
|
||||
@@ -882,6 +890,8 @@ void FancyZones::UpdateWorkAreas(bool updateWindowPositions) noexcept
|
||||
|
||||
if (ShouldWorkAreasBeRecreated(monitors, currentVirtualDesktop, workAreas))
|
||||
{
|
||||
// See comment above the matching Clear() in the span-zones branch.
|
||||
MoveSizeEnd();
|
||||
m_workAreaConfiguration.Clear();
|
||||
for (const auto& monitor : monitors)
|
||||
{
|
||||
@@ -1094,6 +1104,9 @@ void FancyZones::SettingsUpdate(SettingId id)
|
||||
break;
|
||||
case SettingId::SpanZonesAcrossMonitors:
|
||||
{
|
||||
// See UpdateWorkAreas() — same WindowMouseSnap dangling-WorkArea*
|
||||
// hazard if the user toggles this setting mid-drag.
|
||||
MoveSizeEnd();
|
||||
m_workAreaConfiguration.Clear();
|
||||
PostMessageW(m_window, WM_PRIV_INIT, NULL, NULL);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,14 @@ void OnThreadExecutor::worker_thread()
|
||||
|
||||
OnThreadExecutor::~OnThreadExecutor()
|
||||
{
|
||||
_shutdown_request = true;
|
||||
{
|
||||
// Modify the shared shutdown flag while holding the mutex so the
|
||||
// worker reliably observes it on its next wake. Without this, a notify
|
||||
// racing the worker entering _task_cv.wait can be missed and the join
|
||||
// below hangs forever.
|
||||
std::lock_guard lock{ _task_mutex };
|
||||
_shutdown_request = true;
|
||||
}
|
||||
_task_cv.notify_one();
|
||||
_worker_thread.join();
|
||||
}
|
||||
|
||||
@@ -115,6 +115,11 @@ WorkArea::WorkArea(HINSTANCE hinstance, const FancyZonesDataTypes::WorkAreaId& u
|
||||
|
||||
WorkArea::~WorkArea()
|
||||
{
|
||||
// Tear down the renderer (joining its background thread) before returning
|
||||
// the HWND to the pool. Otherwise, the render thread can still be drawing
|
||||
// through m_renderTarget into an HWND that has already been recycled by a
|
||||
// subsequent NewZonesOverlayWindow call.
|
||||
m_zonesOverlay.reset();
|
||||
windowPool.FreeZonesOverlayWindow(m_window);
|
||||
}
|
||||
|
||||
|
||||
@@ -340,13 +340,19 @@ void ZonesOverlay::DrawActiveZoneSet(const ZonesMap& zones,
|
||||
|
||||
ZonesOverlay::~ZonesOverlay()
|
||||
{
|
||||
// Constructor early-returns (e.g. CreateHwndRenderTarget failing during a
|
||||
// display-driver TDR) leave m_renderThread default-constructed; calling
|
||||
// join() on a non-joinable thread terminates the process.
|
||||
if (m_renderThread.joinable())
|
||||
{
|
||||
std::unique_lock lock(m_mutex);
|
||||
m_abortThread = true;
|
||||
m_shouldRender = true;
|
||||
{
|
||||
std::unique_lock lock(m_mutex);
|
||||
m_abortThread = true;
|
||||
m_shouldRender = true;
|
||||
}
|
||||
m_cv.notify_all();
|
||||
m_renderThread.join();
|
||||
}
|
||||
m_cv.notify_all();
|
||||
m_renderThread.join();
|
||||
|
||||
if (m_renderTarget)
|
||||
{
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
</PropertyGroup>
|
||||
<!-- Props that are constant for both Debug and Release configurations -->
|
||||
<PropertyGroup Label="Configuration">
|
||||
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
<SpectreMitigation>Spectre</SpectreMitigation>
|
||||
@@ -164,7 +163,7 @@
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets" Condition="Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" />
|
||||
</ImportGroup>
|
||||
<Import Project="..\..\..\..\deps\spdlog.props" />
|
||||
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
|
||||
@@ -181,7 +180,7 @@
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets'))" />
|
||||
</Target>
|
||||
<Target Name="FakeResourcesPriMerge" BeforeTargets="FinalizeBuildStatus" DependsOnTargets="CopyFilesToOutputDirectory">
|
||||
<Message Text="Renaming Microsoft.UI.Xaml.pri to resources.pri" />
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
<package id="Microsoft.Toolkit.Win32.UI.XamlApplication" version="6.1.3" targetFramework="native" />
|
||||
<package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" />
|
||||
<package id="Microsoft.VCRTForwarders.140" version="1.0.7" targetFramework="native" />
|
||||
<package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" />
|
||||
<package id="Microsoft.Web.WebView2" version="1.0.4022.49" targetFramework="native" />
|
||||
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -15,7 +15,6 @@
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Label="Configuration">
|
||||
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
@@ -101,7 +100,7 @@
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
|
||||
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" />
|
||||
</ImportGroup>
|
||||
<Import Project="..\..\..\..\deps\spdlog.props" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
@@ -114,7 +113,7 @@
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
|
||||
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.4022.49\build\native\Microsoft.Web.WebView2.targets'))" />
|
||||
</Target>
|
||||
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
|
||||
<Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory)\..\KeyboardManagerEditor\ resource.base.h resource.h KeyboardManagerEditor.base.rc KeyboardManagerEditor.rc" />
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<packages>
|
||||
<package id="Microsoft.Toolkit.Win32.UI.XamlApplication" version="6.1.3" targetFramework="native" />
|
||||
<package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" />
|
||||
<package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" />
|
||||
<package id="Microsoft.Web.WebView2" version="1.0.4022.49" targetFramework="native" />
|
||||
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -22,6 +22,7 @@ public partial class PowerAccent : IDisposable
|
||||
private const double ScreenMinPadding = 150;
|
||||
|
||||
private bool _visible;
|
||||
private int _showGeneration;
|
||||
private string[] _characters = Array.Empty<string>();
|
||||
private string[] _characterDescriptions = Array.Empty<string>();
|
||||
private int _selectedIndex = -1;
|
||||
@@ -98,6 +99,10 @@ public partial class PowerAccent : IDisposable
|
||||
_initialShiftState = WindowsFunctions.IsShiftState();
|
||||
_visible = true;
|
||||
|
||||
// Each summon gets a generation id so a delayed render queued by an earlier
|
||||
// press can't fire for a newer one (or after the toolbar was hidden).
|
||||
int generation = ++_showGeneration;
|
||||
|
||||
_characters = GetCharacters(letterKey);
|
||||
_characterDescriptions = GetCharacterDescriptions(_characters);
|
||||
_showUnicodeDescription = _settingService.ShowUnicodeDescription;
|
||||
@@ -105,7 +110,7 @@ public partial class PowerAccent : IDisposable
|
||||
Task.Delay(_settingService.InputTime).ContinueWith(
|
||||
t =>
|
||||
{
|
||||
if (_visible)
|
||||
if (_visible && generation == _showGeneration)
|
||||
{
|
||||
OnChangeDisplay?.Invoke(true, _characters);
|
||||
}
|
||||
@@ -237,6 +242,7 @@ public partial class PowerAccent : IDisposable
|
||||
OnChangeDisplay?.Invoke(false, null);
|
||||
_selectedIndex = -1;
|
||||
_visible = false;
|
||||
_showGeneration++;
|
||||
}
|
||||
|
||||
private void ProcessNextChar(TriggerKey triggerKey, bool shiftPressed)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user