mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
Add native CLI shims for PowerToys command-line tools (#48631)
## Summary of the Pull Request Adds a native C++ multi-call shim that exposes existing PowerToys CLIs through `PATH`. The shims are installed under the PowerToys `bin` subfolder and follow the `PowerToys.<ModuleName>.CLI.exe` naming convention. The launcher preserves the raw argument tail, shares the caller's console, and returns the target process exit code. | PATH-visible command | Target executable | | --- | --- | | `PowerToys.FancyZones.CLI.exe` | `FancyZonesCLI.exe` | | `PowerToys.ImageResizer.CLI.exe` | `WinUI3Apps/PowerToys.ImageResizerCLI.exe` | | `PowerToys.FileLocksmith.CLI.exe` | `FileLocksmithCLI.exe` | | `PowerToys.PowerDisplay.CLI.exe` | `WinUI3Apps/PowerToys.PowerDisplay.Cli.exe` | proof of this work: <img width="1044" height="294" alt="image" src="https://github.com/user-attachments/assets/b659c552-5c08-4430-85c3-eba48f286eb0" /> <img width="1137" height="244" alt="image" src="https://github.com/user-attachments/assets/5fed493f-dc30-428d-a618-bf612ccf3635" /> <img width="1727" height="868" alt="image" src="https://github.com/user-attachments/assets/5c32fd2e-4a3a-4138-b968-fb5434eebec3" /> ## PR Checklist - [x] Closes: #48634 - [x] **Communication:** Discussed with core contributors in this PR - [x] **Tests:** Added/updated and all pass - [ ] **Localization:** CLI diagnostic messages are not localized - [x] **Dev docs:** Updated CLI naming and installation conventions - [x] **New binaries:** Added on the required places - [x] Signing JSON - [x] WiX installer entries - [x] CI builds through `PowerToys.slnx`; no dedicated YML step is required - [x] The existing release pipeline covers the solution and installer - [x] **Documentation updated:** `doc/devdocs/cli-conventions.md` ## Detailed Description of the Pull Request / Additional comments - Uses one native launcher binary for all commands and resolves the target from the invoked shim filename. - Installs PATH-visible shims under `PowerToys\bin`. - Keeps the existing module CLI binaries and their deployment locations unchanged. - Rejects the previous unsuffixed and `*cli` command aliases. ## Validation Steps Performed - Built `tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj` in `Release|x64`: 0 warnings, 0 errors. - Ran `CliShim.UnitTests.dll` with `vstest.console.exe`: 5/5 tests passed. - Verified the CLI manifest, WiX command names, and `bin` installation directory are synchronized. - Ran `git diff --check`. --------- Co-authored-by: Yu Leng <yuleng@microsoft.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d11c4221-248f-44a9-85fb-7017ed43f4ce
This commit is contained in:
@@ -65,6 +65,8 @@
|
|||||||
"FancyZonesCLI.exe",
|
"FancyZonesCLI.exe",
|
||||||
"FancyZonesCLI.dll",
|
"FancyZonesCLI.dll",
|
||||||
|
|
||||||
|
"CliShim\\PowerToys.CliShim.exe",
|
||||||
|
|
||||||
"PowerToys.GcodePreviewHandler.dll",
|
"PowerToys.GcodePreviewHandler.dll",
|
||||||
"PowerToys.GcodePreviewHandler.exe",
|
"PowerToys.GcodePreviewHandler.exe",
|
||||||
"PowerToys.GcodePreviewHandlerCpp.dll",
|
"PowerToys.GcodePreviewHandlerCpp.dll",
|
||||||
|
|||||||
@@ -1260,5 +1260,7 @@
|
|||||||
<Project Path="src/Update/PowerToys.Update.vcxproj" Id="44ce9ae1-4390-42c5-bacc-0fd6b40aa203" />
|
<Project Path="src/Update/PowerToys.Update.vcxproj" Id="44ce9ae1-4390-42c5-bacc-0fd6b40aa203" />
|
||||||
<Project Path="src/runner/UnitTests/UnitTests-Runner.vcxproj" Id="97bdacf8-261d-4e23-a708-a27e0b60e444" />
|
<Project Path="src/runner/UnitTests/UnitTests-Runner.vcxproj" Id="97bdacf8-261d-4e23-a708-a27e0b60e444" />
|
||||||
<Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" />
|
<Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" />
|
||||||
|
<Project Path="tools/CliShim/CliShim.vcxproj" Id="8a7fb7fa-65ea-4004-ba73-1b237435a57b" />
|
||||||
|
<Project Path="tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj" Id="d4b0ed68-867d-46b4-a03f-ec52b9fbebe5" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
This document describes the conventions for implementing command-line interfaces (CLI) in PowerToys modules.
|
This document describes the conventions for implementing command-line interfaces (CLI) in PowerToys modules.
|
||||||
|
|
||||||
|
## PATH-Visible Command Naming and Location
|
||||||
|
|
||||||
|
- Name module CLI command shims `PowerToys.<ModuleName>.CLI.exe` (for example, `PowerToys.ImageResizer.CLI.exe`).
|
||||||
|
- Install these shims in the `bin` subfolder of the PowerToys installation directory, which the installer adds to `PATH`.
|
||||||
|
|
||||||
|
Every command is the same `PowerToys.CliShim.exe` payload (`tools/CliShim/`) installed under a different name. The shim resolves which CLI to launch from its own file name, forwards the raw argument tail unchanged, shares the caller's console, and returns the CLI's exit code. The CLI runs in a job object owned by the shim, so killing the shim kills the CLI with it; processes the CLI itself starts (the Settings window, for example) break away and survive.
|
||||||
|
|
||||||
|
On a per-machine install the `bin` folder is created with a protected DACL (`MachinePathFolderSddl` in `installer/PowerToysSetupVNext/Common.wxi`) so that a custom installation root cannot leave a machine-`PATH` folder writable by standard users. Author that `<CreateFolder>` on the same component as the folder's `<Environment>` `PATH` entry, so the two cannot drift apart.
|
||||||
|
|
||||||
|
### Adding a new shim
|
||||||
|
|
||||||
|
1. Add a `<CliShim>` item to `tools/CliShim/CliShimManifest.props` with the command name and the target's path relative to `bin`. Write that path with `/` separators, and against the *installed* layout (see [Signing and Deployment](#signing-and-deployment)) - which is where the CLI ends up, not where it is built from.
|
||||||
|
2. Add the matching `<Component>` and `<ComponentRef>` to `installer/PowerToysSetupVNext/CliShims.wxs`, using the command name as the `File/@Name`.
|
||||||
|
|
||||||
|
`CliShim.vcxproj` fails the build if the command names in those two drift apart, `build-installer.ps1` fails the build if a `RelativeTarget` does not resolve to a real executable, and `CliShim.UnitTests` generates its expectations from the same manifest, so there is no third list to update.
|
||||||
|
|
||||||
|
### Shim exit codes
|
||||||
|
|
||||||
|
The shim returns the target CLI's exit code unchanged. It substitutes one of its own codes only when the CLI never ran, using values outside the range the CLIs use themselves:
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `9009` | No CLI is mapped to the invoked command name (matches `cmd.exe`'s "command not found"). |
|
||||||
|
| `9010` | The mapped target executable is missing from the installation. |
|
||||||
|
| `9011` | The shim could not start the target, including when it cannot resolve its own path. |
|
||||||
|
|
||||||
## Library
|
## Library
|
||||||
|
|
||||||
Use the **System.CommandLine** library for CLI argument parsing. This is already defined in `Directory.Packages.props`:
|
Use the **System.CommandLine** library for CLI argument parsing. This is already defined in `Directory.Packages.props`:
|
||||||
@@ -89,5 +115,5 @@ Reference implementations:
|
|||||||
|
|
||||||
- CLI executables are signed automatically in CI/CD.
|
- CLI executables are signed automatically in CI/CD.
|
||||||
- **New CLI tools**: Add your executable and dll to `.pipelines/ESRPSigning_core.json` in the signing list.
|
- **New CLI tools**: Add your executable and dll to `.pipelines/ESRPSigning_core.json` in the signing list.
|
||||||
- CLI executables are deployed alongside their parent module (e.g., `C:\Program Files\PowerToys\modules\[ModuleName]\`).
|
- CLI executables are deployed either to the installation root (e.g., `C:\Program Files\PowerToys\FancyZonesCLI.exe`) or, for WinUI 3 modules, next to their module in `WinUI3Apps\` (e.g., `C:\Program Files\PowerToys\WinUI3Apps\PowerToys.ImageResizerCLI.exe`). PATH-visible shims are deployed to `C:\Program Files\PowerToys\bin\`, and a shim's `RelativeTarget` is resolved from that `bin` folder against the *installed* layout - not against the source tree.
|
||||||
- Use self-contained deployment (import `Common.SelfContained.props`).
|
- Use self-contained deployment (import `Common.SelfContained.props`).
|
||||||
|
|||||||
85
installer/PowerToysSetupVNext/CliShims.wxs
Normal file
85
installer/PowerToysSetupVNext/CliShims.wxs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||||
|
|
||||||
|
<?include $(sys.CURRENTDIR)\Common.wxi?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
PATH-visible shims forward to the installed PowerToys CLIs. Every command is the same
|
||||||
|
PowerToys.CliShim.exe payload installed under a different name; the shim resolves its target
|
||||||
|
from that name. tools\CliShim\CliShimManifest.props is the source of truth for the list and
|
||||||
|
the CliShim project fails the build if the two drift apart, so keep the File/@Name values and
|
||||||
|
that manifest in sync.
|
||||||
|
-->
|
||||||
|
<Fragment>
|
||||||
|
<DirectoryRef Id="CliFolder">
|
||||||
|
<Component Id="CliShim_PowerToys_FancyZones_CLI_exe" Guid="63800E2A-21B9-4A92-AD25-9C1DC7593F4F" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="CliShim_PowerToys_FancyZones_CLI_exe" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<File Source="$(var.BinDir)CliShim\PowerToys.CliShim.exe" Name="PowerToys.FancyZones.CLI.exe" Id="CliShim_PowerToys.FancyZones.CLI.exe" Checksum="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="CliShim_PowerToys_ImageResizer_CLI_exe" Guid="2BC274AA-FECD-4A85-BF70-06670A6B99A5" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="CliShim_PowerToys_ImageResizer_CLI_exe" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<File Source="$(var.BinDir)CliShim\PowerToys.CliShim.exe" Name="PowerToys.ImageResizer.CLI.exe" Id="CliShim_PowerToys.ImageResizer.CLI.exe" Checksum="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="CliShim_PowerToys_FileLocksmith_CLI_exe" Guid="2DD830DD-78D4-46EE-B23B-3EAB3D838EDF" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="CliShim_PowerToys_FileLocksmith_CLI_exe" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<File Source="$(var.BinDir)CliShim\PowerToys.CliShim.exe" Name="PowerToys.FileLocksmith.CLI.exe" Id="CliShim_PowerToys.FileLocksmith.CLI.exe" Checksum="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="CliShim_PowerToys_PowerDisplay_CLI_exe" Guid="E98A36C9-384E-48CB-A844-A6A49E4138D9" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="CliShim_PowerToys_PowerDisplay_CLI_exe" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<File Source="$(var.BinDir)CliShim\PowerToys.CliShim.exe" Name="PowerToys.PowerDisplay.CLI.exe" Id="CliShim_PowerToys.PowerDisplay.CLI.exe" Checksum="yes" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
|
<!-- MSI broadcasts WM_SETTINGCHANGE; terminals open before install must be reopened. -->
|
||||||
|
<?if $(var.PerUser) = "true" ?>
|
||||||
|
<Component Id="cli_env_path_user" Guid="87AA86E8-81E8-44CB-9519-FC70CF7BEA56" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="cli_env_path_user" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<Environment Id="AddCliToUserPath" Name="PATH" Action="set" Part="last" System="no" Value="[CliFolder]" />
|
||||||
|
</Component>
|
||||||
|
<?else?>
|
||||||
|
<Component Id="cli_env_path_machine" Guid="739F3916-2247-4F1B-8BBD-0949A5B870F7" Bitness="always64">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="cli_env_path_machine" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<Environment Id="AddCliToMachinePath" Name="PATH" Action="set" Part="last" System="yes" Value="[CliFolder]" />
|
||||||
|
<!--
|
||||||
|
On the same component as the PATH entry on purpose: bin is only worth locking down
|
||||||
|
because it is on the machine PATH, and this way the two cannot drift apart.
|
||||||
|
CreateFolders applies the DACL before InstallFiles writes the shims, so the shims
|
||||||
|
inherit it and need no PermissionEx of their own.
|
||||||
|
-->
|
||||||
|
<CreateFolder>
|
||||||
|
<PermissionEx Sddl="$(var.MachinePathFolderSddl)" />
|
||||||
|
</CreateFolder>
|
||||||
|
</Component>
|
||||||
|
<?endif?>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<ComponentGroup Id="CliShimsComponentGroup">
|
||||||
|
<Component Id="RemoveCliFolder" Guid="831C56D8-9FEB-41B2-8A8C-BD49487479BB" Directory="CliFolder">
|
||||||
|
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||||
|
<RegistryValue Type="string" Name="RemoveCliFolder" Value="" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<RemoveFolder Id="RemoveFolderCliFolder" Directory="CliFolder" On="uninstall" />
|
||||||
|
</Component>
|
||||||
|
<ComponentRef Id="CliShim_PowerToys_FancyZones_CLI_exe" />
|
||||||
|
<ComponentRef Id="CliShim_PowerToys_ImageResizer_CLI_exe" />
|
||||||
|
<ComponentRef Id="CliShim_PowerToys_FileLocksmith_CLI_exe" />
|
||||||
|
<ComponentRef Id="CliShim_PowerToys_PowerDisplay_CLI_exe" />
|
||||||
|
<?if $(var.PerUser) = "true" ?>
|
||||||
|
<ComponentRef Id="cli_env_path_user" />
|
||||||
|
<?else?>
|
||||||
|
<ComponentRef Id="cli_env_path_machine" />
|
||||||
|
<?endif?>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
</Fragment>
|
||||||
|
</Wix>
|
||||||
@@ -54,5 +54,21 @@
|
|||||||
<?define UpgradeCodeGUID="42B84BF7-5FBF-473B-9C8B-049DC16F7708" ?>
|
<?define UpgradeCodeGUID="42B84BF7-5FBF-473B-9C8B-049DC16F7708" ?>
|
||||||
<?endif?>
|
<?endif?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
DACL for every folder PowerToys adds to the machine PATH.
|
||||||
|
|
||||||
|
D:P is the point: it drops inherited ACEs. A per-machine INSTALLFOLDER is overridable
|
||||||
|
(PowerToys.wxs InstallFolder), and a custom root such as C:\PowerToys inherits
|
||||||
|
"Authenticated Users: Modify" from the drive root - which would let any standard user plant
|
||||||
|
binaries in a folder that every process on the machine searches. SYSTEM and Administrators
|
||||||
|
keep full control; BUILTIN\Users keep read + execute so non-admins can still run the commands.
|
||||||
|
|
||||||
|
Author this on the same component as the folder's Environment PATH entry, so a new
|
||||||
|
PATH-visible folder cannot ship without it. Per-user installs live under %LOCALAPPDATA% and
|
||||||
|
must NOT use this - InstallPrivileges is "limited" there, and this SDDL would leave the
|
||||||
|
installing user with only read + execute on their own install.
|
||||||
|
-->
|
||||||
|
<?define MachinePathFolderSddl="D:PAI(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GRGX;;;BU)(A;OICIIO;GA;;;CO)" ?>
|
||||||
|
|
||||||
<?define BinX32Dir="$(var.RepoDir)x86\$(var.Configuration)\" ?>
|
<?define BinX32Dir="$(var.RepoDir)x86\$(var.Configuration)\" ?>
|
||||||
</Include>
|
</Include>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<RegistryValue Type="string" Name="SecureDSCModulesFolder" Value="" KeyPath="yes" />
|
<RegistryValue Type="string" Name="SecureDSCModulesFolder" Value="" KeyPath="yes" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
<CreateFolder>
|
<CreateFolder>
|
||||||
<PermissionEx Sddl="D:PAI(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GRGX;;;BU)(A;OICIIO;GA;;;CO)" />
|
<PermissionEx Sddl="$(var.MachinePathFolderSddl)" />
|
||||||
</CreateFolder>
|
</CreateFolder>
|
||||||
</Component>
|
</Component>
|
||||||
<?endif?>
|
<?endif?>
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
|
|||||||
<Compile Include="Settings.wxs" />
|
<Compile Include="Settings.wxs" />
|
||||||
<Compile Include="ShortcutGuide.wxs" />
|
<Compile Include="ShortcutGuide.wxs" />
|
||||||
<Compile Include="Tools.wxs" />
|
<Compile Include="Tools.wxs" />
|
||||||
|
<Compile Include="CliShims.wxs" />
|
||||||
<Compile Include="MouseWithoutBorders.wxs" />
|
<Compile Include="MouseWithoutBorders.wxs" />
|
||||||
<Compile Include="WinUI3Applications.wxs" />
|
<Compile Include="WinUI3Applications.wxs" />
|
||||||
<Compile Include="MonacoSRC.wxs" />
|
<Compile Include="MonacoSRC.wxs" />
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
<ComponentGroupRef Id="DscResourcesComponentGroup" />
|
<ComponentGroupRef Id="DscResourcesComponentGroup" />
|
||||||
<ComponentGroupRef Id="WindowsAppSDKComponentGroup" />
|
<ComponentGroupRef Id="WindowsAppSDKComponentGroup" />
|
||||||
<ComponentGroupRef Id="ToolComponentGroup" />
|
<ComponentGroupRef Id="ToolComponentGroup" />
|
||||||
|
<ComponentGroupRef Id="CliShimsComponentGroup" />
|
||||||
<ComponentGroupRef Id="MonacoSRCHeatGenerated" />
|
<ComponentGroupRef Id="MonacoSRCHeatGenerated" />
|
||||||
<ComponentGroupRef Id="WorkspacesComponentGroup" />
|
<ComponentGroupRef Id="WorkspacesComponentGroup" />
|
||||||
<ComponentGroupRef Id="CmdPalComponentGroup" />
|
<ComponentGroupRef Id="CmdPalComponentGroup" />
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
</Directory>
|
</Directory>
|
||||||
</Directory>
|
</Directory>
|
||||||
<Directory Id="ToolsFolder" Name="Tools" />
|
<Directory Id="ToolsFolder" Name="Tools" />
|
||||||
|
<Directory Id="CliFolder" Name="bin" />
|
||||||
</Directory>
|
</Directory>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
<StandardDirectory Id="ProgramMenuFolder">
|
<StandardDirectory Id="ProgramMenuFolder">
|
||||||
|
|||||||
59
tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj
Normal file
59
tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||||
|
<PropertyGroup Label="Globals">
|
||||||
|
<ProjectGuid>{D4B0ED68-867D-46B4-A03F-EC52B9FBEBE5}</ProjectGuid>
|
||||||
|
<Keyword>Win32Proj</Keyword>
|
||||||
|
<RootNamespace>PowerToysCliShimUnitTests</RootNamespace>
|
||||||
|
<ProjectName>CliShim.UnitTests</ProjectName>
|
||||||
|
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
|
||||||
|
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
|
||||||
|
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\CliShim\</OutDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Label="Configuration">
|
||||||
|
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||||
|
<!-- Supplies the shared command mapping and generates CliShimTargets.g.inc into $(IntDir). -->
|
||||||
|
<Import Project="..\CliShim\CliShimManifest.props" />
|
||||||
|
<PropertyGroup>
|
||||||
|
<VcpkgEnabled>false</VcpkgEnabled>
|
||||||
|
<VcpkgManifestEnabled>false</VcpkgManifestEnabled>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemDefinitionGroup>
|
||||||
|
<ClCompile>
|
||||||
|
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||||
|
<AdditionalIncludeDirectories>$(IntDir);$(MSBuildProjectDirectory)\..\CliShim;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Compile the parser directly; the project reference supplies the integration-test executable. -->
|
||||||
|
<ClCompile Include="..\CliShim\CommandLine.cpp" />
|
||||||
|
<ClCompile Include="CommandLineTests.cpp" />
|
||||||
|
<ClCompile Include="LauncherIntegrationTests.cpp" />
|
||||||
|
<ClInclude Include="..\CliShim\CommandLine.h" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\CliShim\CliShim.vcxproj">
|
||||||
|
<Project>{8A7FB7FA-65EA-4004-BA73-1B237435A57B}</Project>
|
||||||
|
<LinkLibraryDependencies>false</LinkLibraryDependencies>
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||||
|
<Target Name="CopyCliShimUnderTest"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Condition="'$(_IsSkippedTestProject)' != 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_CliShimUnderTest>$(RepoRoot)$(Platform)\$(Configuration)\CliShim\PowerToys.CliShim.exe</_CliShimUnderTest>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Error Condition="!Exists('$(_CliShimUnderTest)')"
|
||||||
|
Text="CLI shim under test was not built: '$(_CliShimUnderTest)'." />
|
||||||
|
<Copy SourceFiles="$(_CliShimUnderTest)"
|
||||||
|
DestinationFolder="$(OutDir)"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
73
tools/CliShim.UnitTests/CommandLineTests.cpp
Normal file
73
tools/CliShim.UnitTests/CommandLineTests.cpp
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <CppUnitTest.h>
|
||||||
|
|
||||||
|
#include "CommandLine.h"
|
||||||
|
|
||||||
|
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||||
|
|
||||||
|
namespace CliShimUnitTests
|
||||||
|
{
|
||||||
|
TEST_CLASS(CommandLineTests)
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TEST_METHOD(StripArgumentZero_ReturnsForwardedTail)
|
||||||
|
{
|
||||||
|
struct Case
|
||||||
|
{
|
||||||
|
const wchar_t* commandLine;
|
||||||
|
const wchar_t* expected;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Case cases[] = {
|
||||||
|
{ L"PowerToys.FancyZones.CLI arg", L"arg" },
|
||||||
|
{ L"PowerToys.FancyZones.CLI a b c", L"a b c" },
|
||||||
|
{ L"PowerToys.FancyZones.CLI", L"" },
|
||||||
|
{ L"PowerToys.FileLocksmith.CLI", L"" },
|
||||||
|
|
||||||
|
{ LR"("C:\Program Files\PowerToys\bin\PowerToys.FancyZones.CLI.exe" arg)", L"arg" },
|
||||||
|
{ LR"("C:\Program Files\PowerToys\bin\PowerToys.FancyZones.CLI.exe")", L"" },
|
||||||
|
|
||||||
|
// Quotes only toggle, so a quoted argv[0] does not end at the closing quote: an
|
||||||
|
// argument glued to it is part of the program name. CommandLineToArgvW would
|
||||||
|
// forward `--help` here; the CRT - and so every target CLI - sees no arguments.
|
||||||
|
{ LR"("C:\bin\PowerToys.FancyZones.CLI.exe"--help)", L"" },
|
||||||
|
|
||||||
|
// Partially quoted program names - the batch idiom of quoting only the variable,
|
||||||
|
// `"%ProgramFiles%"\PowerToys\bin\...`, which cmd.exe passes through verbatim - are
|
||||||
|
// resolved exactly as the CRT resolves them, so the target sees the same tail it
|
||||||
|
// would have seen had the caller invoked it directly rather than through the shim.
|
||||||
|
{ LR"("C:\Program Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg)", L"arg" },
|
||||||
|
{ LR"(C:\Program" Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg)", L"arg" },
|
||||||
|
{ LR"("C:\Program Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe)", L"" },
|
||||||
|
|
||||||
|
{ LR"("C:\bin\PowerToys.FancyZones.CLI.exe" "a b")", LR"("a b")" },
|
||||||
|
{ LR"(PowerToys.FancyZones.CLI --path "C:\a b\c.png")", LR"(--path "C:\a b\c.png")" },
|
||||||
|
|
||||||
|
{ L"PowerToys.FancyZones.CLI\targ", L"arg" },
|
||||||
|
{ L"PowerToys.FancyZones.CLI \t arg", L"arg" },
|
||||||
|
|
||||||
|
// Non-shell CreateProcessW callers can prepend whitespace; argv[0] must not leak.
|
||||||
|
{ L" PowerToys.FancyZones.CLI arg", L"arg" },
|
||||||
|
{ L" PowerToys.FancyZones.CLI", L"" },
|
||||||
|
{ LR"( "C:\bin\PowerToys.FancyZones.CLI.exe" arg)", L"arg" },
|
||||||
|
|
||||||
|
{ L"", L"" },
|
||||||
|
|
||||||
|
// An unterminated argv[0] quote consumes the remaining command line.
|
||||||
|
{ LR"("C:\Program Files\app)", L"" },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const Case& testCase : cases)
|
||||||
|
{
|
||||||
|
const std::wstring actual = CommandLine::StripArgumentZero(testCase.commandLine);
|
||||||
|
const std::wstring message = std::wstring(L"input: <") + testCase.commandLine + L">";
|
||||||
|
Assert::AreEqual(std::wstring(testCase.expected), actual, message.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
631
tools/CliShim.UnitTests/LauncherIntegrationTests.cpp
Normal file
631
tools/CliShim.UnitTests/LauncherIntegrationTests.cpp
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <Windows.h>
|
||||||
|
|
||||||
|
#include <TlHelp32.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <system_error>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <CppUnitTest.h>
|
||||||
|
|
||||||
|
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||||
|
|
||||||
|
extern "C" IMAGE_DOS_HEADER __ImageBase;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr DWORD ExitCommandNotMapped = 9009;
|
||||||
|
constexpr DWORD ExitTargetNotFound = 9010;
|
||||||
|
constexpr DWORD ForwardedExitCode = 37;
|
||||||
|
|
||||||
|
// How often the process-tree helpers re-scan while waiting for a process to appear.
|
||||||
|
constexpr DWORD ProcessPollIntervalMilliseconds = 50;
|
||||||
|
|
||||||
|
// How many times a temporary directory removal is retried before it is given up on.
|
||||||
|
constexpr int CleanupAttempts = 20;
|
||||||
|
|
||||||
|
// Only used to unblock the test run if the shim ever hangs; the wait result is asserted.
|
||||||
|
constexpr DWORD TimeoutTerminationExitCode = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
struct ShimMapping
|
||||||
|
{
|
||||||
|
const wchar_t* command;
|
||||||
|
const wchar_t* relativeTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generated from CliShimManifest.props, the same table the shim itself is built from, so a
|
||||||
|
// newly mapped command is covered here automatically instead of by hand.
|
||||||
|
constexpr ShimMapping ExpectedMappings[] = {
|
||||||
|
#include "CliShimTargets.g.inc"
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr const wchar_t* RejectedLegacyCommands[] = {
|
||||||
|
L"fancyzones",
|
||||||
|
L"imageresizer",
|
||||||
|
L"filelocksmith",
|
||||||
|
L"powerdisplay",
|
||||||
|
L"fancyzonescli",
|
||||||
|
L"imageresizercli",
|
||||||
|
L"filelocksmithcli",
|
||||||
|
L"powerdisplaycli",
|
||||||
|
};
|
||||||
|
|
||||||
|
class UniqueHandle
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
UniqueHandle() = default;
|
||||||
|
|
||||||
|
explicit UniqueHandle(HANDLE value) noexcept :
|
||||||
|
handle{ value }
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
UniqueHandle(const UniqueHandle&) = delete;
|
||||||
|
UniqueHandle& operator=(const UniqueHandle&) = delete;
|
||||||
|
|
||||||
|
UniqueHandle(UniqueHandle&& other) noexcept :
|
||||||
|
handle{ std::exchange(other.handle, nullptr) }
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
UniqueHandle& operator=(UniqueHandle&& other) noexcept
|
||||||
|
{
|
||||||
|
if (this != &other)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
handle = std::exchange(other.handle, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
~UniqueHandle()
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset() noexcept
|
||||||
|
{
|
||||||
|
if (handle != nullptr && handle != INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
HANDLE Get() const noexcept
|
||||||
|
{
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
HANDLE handle = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::filesystem::path GetTestBinaryDirectory()
|
||||||
|
{
|
||||||
|
wchar_t modulePath[MAX_PATH]{};
|
||||||
|
const DWORD length = GetModuleFileNameW(
|
||||||
|
reinterpret_cast<HMODULE>(&__ImageBase),
|
||||||
|
modulePath,
|
||||||
|
ARRAYSIZE(modulePath));
|
||||||
|
|
||||||
|
Assert::IsTrue(length > 0 && length < ARRAYSIZE(modulePath), L"Could not locate the test module.");
|
||||||
|
return std::filesystem::path{ modulePath }.parent_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path GetShimUnderTest()
|
||||||
|
{
|
||||||
|
return GetTestBinaryDirectory() / L"PowerToys.CliShim.exe";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path GetSystemDirectoryPath()
|
||||||
|
{
|
||||||
|
wchar_t systemDirectory[MAX_PATH]{};
|
||||||
|
const UINT length = GetSystemDirectoryW(systemDirectory, ARRAYSIZE(systemDirectory));
|
||||||
|
|
||||||
|
Assert::IsTrue(length > 0 && length < ARRAYSIZE(systemDirectory), L"Could not locate the system directory.");
|
||||||
|
return systemDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path GetSystemCommandInterpreter()
|
||||||
|
{
|
||||||
|
return GetSystemDirectoryPath() / L"cmd.exe";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path CreateTemporaryDirectory()
|
||||||
|
{
|
||||||
|
wchar_t temporaryRoot[MAX_PATH]{};
|
||||||
|
const DWORD rootLength = GetTempPathW(ARRAYSIZE(temporaryRoot), temporaryRoot);
|
||||||
|
Assert::IsTrue(rootLength > 0 && rootLength < ARRAYSIZE(temporaryRoot), L"Could not locate the temporary directory.");
|
||||||
|
|
||||||
|
wchar_t temporaryFile[MAX_PATH]{};
|
||||||
|
Assert::IsTrue(
|
||||||
|
GetTempFileNameW(temporaryRoot, L"PTS", 0, temporaryFile) != 0,
|
||||||
|
L"Could not reserve a temporary path.");
|
||||||
|
Assert::IsTrue(DeleteFileW(temporaryFile), L"Could not remove the temporary placeholder file.");
|
||||||
|
Assert::IsTrue(CreateDirectoryW(temporaryFile, nullptr), L"Could not create the temporary directory.");
|
||||||
|
|
||||||
|
return temporaryFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TemporaryDirectory
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TemporaryDirectory() :
|
||||||
|
path{ CreateTemporaryDirectory() }
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
~TemporaryDirectory()
|
||||||
|
{
|
||||||
|
// A process that has just been terminated can still have its image mapped for a moment,
|
||||||
|
// which keeps the copied executable open, so give the removal a few attempts.
|
||||||
|
for (int attempt = 0; attempt < CleanupAttempts; ++attempt)
|
||||||
|
{
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::remove_all(path, error);
|
||||||
|
if (!error)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sleep(ProcessPollIntervalMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::filesystem::path& GetPath() const noexcept
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path path;
|
||||||
|
};
|
||||||
|
|
||||||
|
void CopyExecutable(const std::filesystem::path& source, const std::filesystem::path& destination)
|
||||||
|
{
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::create_directories(destination.parent_path(), error);
|
||||||
|
Assert::AreEqual(0, error.value(), L"Could not create the destination directory.");
|
||||||
|
|
||||||
|
std::filesystem::copy_file(
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
std::filesystem::copy_options::overwrite_existing,
|
||||||
|
error);
|
||||||
|
Assert::AreEqual(0, error.value(), L"Could not copy the executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UniqueHandle OpenNul(const DWORD access)
|
||||||
|
{
|
||||||
|
SECURITY_ATTRIBUTES attributes{};
|
||||||
|
attributes.nLength = sizeof(attributes);
|
||||||
|
attributes.bInheritHandle = TRUE;
|
||||||
|
|
||||||
|
UniqueHandle handle{ CreateFileW(
|
||||||
|
L"NUL",
|
||||||
|
access,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||||
|
&attributes,
|
||||||
|
OPEN_EXISTING,
|
||||||
|
0,
|
||||||
|
nullptr) };
|
||||||
|
Assert::IsTrue(handle.Get() != INVALID_HANDLE_VALUE, L"Could not open NUL.");
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolhelp is the whole story for observing the shim's process tree. A child has to be
|
||||||
|
// selected by image name rather than by "the first child", because the shim is also given a
|
||||||
|
// console host child; and a process that outlived its parent has to be found by image name
|
||||||
|
// alone, because by then its parent process id refers to a process that no longer exists.
|
||||||
|
UniqueHandle OpenProcessByImageName(
|
||||||
|
const std::wstring& imageName,
|
||||||
|
const std::optional<DWORD> parentProcessId,
|
||||||
|
const DWORD access)
|
||||||
|
{
|
||||||
|
UniqueHandle snapshot{ CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if (snapshot.Get() == INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
PROCESSENTRY32W entry{};
|
||||||
|
entry.dwSize = sizeof(entry);
|
||||||
|
|
||||||
|
for (BOOL found = Process32FirstW(snapshot.Get(), &entry); found; found = Process32NextW(snapshot.Get(), &entry))
|
||||||
|
{
|
||||||
|
if (parentProcessId.has_value() && entry.th32ParentProcessID != *parentProcessId)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CompareStringOrdinal(entry.szExeFile, -1, imageName.c_str(), -1, TRUE) != CSTR_EQUAL)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
UniqueHandle process{ OpenProcess(access, FALSE, entry.th32ProcessID) };
|
||||||
|
if (process.Get() != nullptr)
|
||||||
|
{
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
UniqueHandle WaitForProcessByImageName(
|
||||||
|
const std::wstring& imageName,
|
||||||
|
const std::optional<DWORD> parentProcessId,
|
||||||
|
const DWORD access,
|
||||||
|
const DWORD timeoutMilliseconds)
|
||||||
|
{
|
||||||
|
for (DWORD elapsed = 0;; elapsed += ProcessPollIntervalMilliseconds)
|
||||||
|
{
|
||||||
|
UniqueHandle process = OpenProcessByImageName(imageName, parentProcessId, access);
|
||||||
|
if (process.Get() != nullptr || elapsed >= timeoutMilliseconds)
|
||||||
|
{
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sleep(ProcessPollIntervalMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LaunchedProcess
|
||||||
|
{
|
||||||
|
UniqueHandle process;
|
||||||
|
DWORD processId = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CppUnitTest assertions throw, so anything the job-object tests start has to be torn down by
|
||||||
|
// a destructor rather than by a line at the end of the test: both tests deliberately start a
|
||||||
|
// process that never exits on its own, and a failing assertion would otherwise leave it
|
||||||
|
// running - and its executable locked - for as long as the agent lives.
|
||||||
|
class ProcessKiller
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit ProcessKiller(UniqueHandle process) noexcept :
|
||||||
|
handle{ std::move(process) }
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessKiller(const ProcessKiller&) = delete;
|
||||||
|
ProcessKiller& operator=(const ProcessKiller&) = delete;
|
||||||
|
|
||||||
|
~ProcessKiller()
|
||||||
|
{
|
||||||
|
if (handle.Get() != nullptr)
|
||||||
|
{
|
||||||
|
TerminateProcess(handle.Get(), 0);
|
||||||
|
WaitForSingleObject(handle.Get(), 5'000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HANDLE Get() const noexcept
|
||||||
|
{
|
||||||
|
return handle.Get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
UniqueHandle handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts the executable without waiting for it. When either standard handle is supplied the
|
||||||
|
// child is started with inherited handles so the shim's own redirection behaviour is
|
||||||
|
// exercised; the handles left null keep whatever the test host is using.
|
||||||
|
LaunchedProcess StartProcess(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
const std::wstring& arguments,
|
||||||
|
HANDLE standardInput,
|
||||||
|
HANDLE standardOutput)
|
||||||
|
{
|
||||||
|
std::wstring commandLine = L"\"" + executable.wstring() + L"\"";
|
||||||
|
if (!arguments.empty())
|
||||||
|
{
|
||||||
|
commandLine.push_back(L' ');
|
||||||
|
commandLine.append(arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool redirect = standardInput != nullptr || standardOutput != nullptr;
|
||||||
|
|
||||||
|
STARTUPINFOW startupInfo{};
|
||||||
|
startupInfo.cb = sizeof(startupInfo);
|
||||||
|
if (redirect)
|
||||||
|
{
|
||||||
|
startupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
startupInfo.hStdInput = standardInput != nullptr ? standardInput : GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
startupInfo.hStdOutput = standardOutput != nullptr ? standardOutput : GetStdHandle(STD_OUTPUT_HANDLE);
|
||||||
|
startupInfo.hStdError = standardOutput != nullptr ? standardOutput : GetStdHandle(STD_ERROR_HANDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
PROCESS_INFORMATION processInfo{};
|
||||||
|
|
||||||
|
if (!CreateProcessW(
|
||||||
|
executable.c_str(),
|
||||||
|
commandLine.data(),
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
redirect ? TRUE : FALSE,
|
||||||
|
CREATE_NO_WINDOW,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
&startupInfo,
|
||||||
|
&processInfo))
|
||||||
|
{
|
||||||
|
const std::wstring message = L"CreateProcessW failed with error " + std::to_wstring(GetLastError()) + L".";
|
||||||
|
Assert::Fail(message.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(processInfo.hThread);
|
||||||
|
return { UniqueHandle{ processInfo.hProcess }, processInfo.dwProcessId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the executable to completion and returns its exit code.
|
||||||
|
DWORD RunProcess(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
const std::wstring& arguments,
|
||||||
|
HANDLE standardInput,
|
||||||
|
HANDLE standardOutput)
|
||||||
|
{
|
||||||
|
const LaunchedProcess launched = StartProcess(executable, arguments, standardInput, standardOutput);
|
||||||
|
|
||||||
|
const DWORD waitResult = WaitForSingleObject(launched.process.Get(), 30'000);
|
||||||
|
if (waitResult != WAIT_OBJECT_0)
|
||||||
|
{
|
||||||
|
TerminateProcess(launched.process.Get(), TimeoutTerminationExitCode);
|
||||||
|
WaitForSingleObject(launched.process.Get(), 5'000);
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD exitCode = 0;
|
||||||
|
const BOOL gotExitCode = GetExitCodeProcess(launched.process.Get(), &exitCode);
|
||||||
|
|
||||||
|
Assert::AreEqual(static_cast<DWORD>(WAIT_OBJECT_0), waitResult, L"The shim process did not exit.");
|
||||||
|
Assert::IsTrue(gotExitCode, L"Could not read the shim process exit code.");
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD RunAndGetExitCode(const std::filesystem::path& executable, const std::wstring& arguments = {})
|
||||||
|
{
|
||||||
|
return RunProcess(executable, arguments, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RunAndCaptureStandardOutput(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
const std::wstring& arguments,
|
||||||
|
const std::filesystem::path& capturePath,
|
||||||
|
DWORD& exitCode)
|
||||||
|
{
|
||||||
|
SECURITY_ATTRIBUTES attributes{};
|
||||||
|
attributes.nLength = sizeof(attributes);
|
||||||
|
attributes.bInheritHandle = TRUE;
|
||||||
|
|
||||||
|
UniqueHandle output{ CreateFileW(
|
||||||
|
capturePath.c_str(),
|
||||||
|
GENERIC_WRITE,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||||
|
&attributes,
|
||||||
|
CREATE_ALWAYS,
|
||||||
|
FILE_ATTRIBUTE_NORMAL,
|
||||||
|
nullptr) };
|
||||||
|
Assert::IsTrue(output.Get() != INVALID_HANDLE_VALUE, L"Could not create the output capture file.");
|
||||||
|
|
||||||
|
const UniqueHandle input = OpenNul(GENERIC_READ);
|
||||||
|
|
||||||
|
exitCode = RunProcess(executable, arguments, input.Get(), output.Get());
|
||||||
|
output.Reset();
|
||||||
|
|
||||||
|
std::ifstream stream{ capturePath, std::ios::binary };
|
||||||
|
Assert::IsTrue(stream.is_open(), L"Could not read the captured output.");
|
||||||
|
|
||||||
|
std::string captured{ std::istreambuf_iterator<char>{ stream }, std::istreambuf_iterator<char>{} };
|
||||||
|
while (!captured.empty() && (captured.back() == '\r' || captured.back() == '\n'))
|
||||||
|
{
|
||||||
|
captured.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forwarded payload is deliberately ASCII-only so it round-trips through cmd.exe's ECHO.
|
||||||
|
std::string NarrowAscii(std::wstring_view text)
|
||||||
|
{
|
||||||
|
std::string narrow;
|
||||||
|
narrow.reserve(text.size());
|
||||||
|
|
||||||
|
for (const wchar_t character : text)
|
||||||
|
{
|
||||||
|
narrow.push_back(static_cast<char>(character));
|
||||||
|
}
|
||||||
|
|
||||||
|
return narrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace CliShimUnitTests
|
||||||
|
{
|
||||||
|
TEST_CLASS(LauncherIntegrationTests)
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TEST_METHOD(AllMappedCommandsLaunchExpectedRelativeTargets)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path binDirectory = installation.GetPath() / L"bin";
|
||||||
|
const std::filesystem::path targetSource = GetSystemCommandInterpreter();
|
||||||
|
|
||||||
|
for (const ShimMapping& mapping : ExpectedMappings)
|
||||||
|
{
|
||||||
|
const std::filesystem::path shimPath = binDirectory / (std::wstring{ mapping.command } + L".exe");
|
||||||
|
const std::filesystem::path targetPath = (binDirectory / mapping.relativeTarget).lexically_normal();
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
CopyExecutable(targetSource, targetPath);
|
||||||
|
|
||||||
|
const DWORD exitCode = RunAndGetExitCode(shimPath, L"/d /c exit 37");
|
||||||
|
const std::wstring message = L"Command failed: " + std::wstring{ mapping.command };
|
||||||
|
Assert::AreEqual(ForwardedExitCode, exitCode, message.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd.exe's ECHO writes the remainder of its command line out verbatim, so this pins down
|
||||||
|
// that the caller's quoting and spacing survive the hop through the shim unchanged.
|
||||||
|
TEST_METHOD(ArgumentTailIsForwardedVerbatim)
|
||||||
|
{
|
||||||
|
constexpr const wchar_t* argumentTail = LR"(--path "C:\a b\c.png" --size 100 -q)";
|
||||||
|
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
|
||||||
|
|
||||||
|
DWORD exitCode = MAXDWORD;
|
||||||
|
const std::string captured = RunAndCaptureStandardOutput(
|
||||||
|
shimPath,
|
||||||
|
std::wstring{ L"/d /c echo " } + argumentTail,
|
||||||
|
installation.GetPath() / L"captured-output.txt",
|
||||||
|
exitCode);
|
||||||
|
|
||||||
|
Assert::AreEqual(static_cast<DWORD>(0), exitCode, L"The forwarded command did not succeed.");
|
||||||
|
Assert::AreEqual(NarrowAscii(argumentTail), captured, L"The argument tail was not forwarded verbatim.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(UnknownCommandReturnsCommandNotMapped)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"unknown.exe";
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
|
||||||
|
Assert::AreEqual(ExitCommandNotMapped, RunAndGetExitCode(shimPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(LegacyCommandsReturnCommandNotMapped)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path binDirectory = installation.GetPath() / L"bin";
|
||||||
|
|
||||||
|
for (const wchar_t* command : RejectedLegacyCommands)
|
||||||
|
{
|
||||||
|
const std::filesystem::path shimPath = binDirectory / (std::wstring{ command } + L".exe");
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
|
||||||
|
const std::wstring message = L"Legacy command was unexpectedly mapped: " + std::wstring{ command };
|
||||||
|
Assert::AreEqual(ExitCommandNotMapped, RunAndGetExitCode(shimPath), message.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing target must not be reported with an exit code the target CLI itself could
|
||||||
|
// return, so callers can tell the two apart.
|
||||||
|
TEST_METHOD(MissingTargetReturnsTargetNotFound)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
|
||||||
|
Assert::AreEqual(ExitTargetNotFound, RunAndGetExitCode(shimPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shim is the only handle a caller holds on the CLI, so a single-process kill of the
|
||||||
|
// shim must not leave the CLI running - PowerToys.FileLocksmith.CLI --wait polls forever
|
||||||
|
// and prints nothing, which would make the orphan invisible and permanent.
|
||||||
|
TEST_METHOD(TerminatingTheShimTerminatesTheTargetCli)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
|
||||||
|
|
||||||
|
// PAUSE blocks inside the target itself, on a pipe that is never written, so the
|
||||||
|
// target needs no console and starts no worker that could outlive the test.
|
||||||
|
SECURITY_ATTRIBUTES attributes{};
|
||||||
|
attributes.nLength = sizeof(attributes);
|
||||||
|
attributes.bInheritHandle = TRUE;
|
||||||
|
|
||||||
|
HANDLE readEnd = nullptr;
|
||||||
|
HANDLE writeEnd = nullptr;
|
||||||
|
Assert::IsTrue(CreatePipe(&readEnd, &writeEnd, &attributes, 0) != FALSE, L"Could not create the standard input pipe.");
|
||||||
|
|
||||||
|
const UniqueHandle standardInput{ readEnd };
|
||||||
|
const UniqueHandle keepPipeOpen{ writeEnd };
|
||||||
|
const UniqueHandle standardOutput = OpenNul(GENERIC_WRITE);
|
||||||
|
|
||||||
|
LaunchedProcess launchedShim = StartProcess(shimPath, L"/d /c pause", standardInput.Get(), standardOutput.Get());
|
||||||
|
const DWORD shimProcessId = launchedShim.processId;
|
||||||
|
const ProcessKiller shim{ std::move(launchedShim.process) };
|
||||||
|
|
||||||
|
const ProcessKiller target{ WaitForProcessByImageName(
|
||||||
|
L"FancyZonesCLI.exe",
|
||||||
|
shimProcessId,
|
||||||
|
SYNCHRONIZE | PROCESS_TERMINATE,
|
||||||
|
10'000) };
|
||||||
|
Assert::IsTrue(target.Get() != nullptr, L"The shim did not start the target CLI.");
|
||||||
|
Assert::AreEqual(
|
||||||
|
static_cast<DWORD>(WAIT_TIMEOUT),
|
||||||
|
WaitForSingleObject(target.Get(), 200),
|
||||||
|
L"The target CLI exited before the shim was terminated.");
|
||||||
|
|
||||||
|
// taskkill without /T, Process.Kill() without entireProcessTree, a script's own
|
||||||
|
// timeout, stopping the debugger: all of these reach the shim and nothing else.
|
||||||
|
Assert::IsTrue(TerminateProcess(shim.Get(), 1) != FALSE, L"Could not terminate the shim.");
|
||||||
|
|
||||||
|
Assert::AreEqual(
|
||||||
|
static_cast<DWORD>(WAIT_OBJECT_0),
|
||||||
|
WaitForSingleObject(target.Get(), 10'000),
|
||||||
|
L"The target CLI outlived the shim.");
|
||||||
|
|
||||||
|
WaitForSingleObject(shim.Get(), 5'000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror of the test above: JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK keeps the CLI's own
|
||||||
|
// children out of the job, so PowerToys.FancyZones.CLI open-settings can start a
|
||||||
|
// long-lived PowerToys.exe and return without that window dying with the shim.
|
||||||
|
TEST_METHOD(ProcessesStartedByTheTargetCliSurviveTheShim)
|
||||||
|
{
|
||||||
|
TemporaryDirectory installation;
|
||||||
|
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
|
||||||
|
|
||||||
|
// Once the CLI that started it has exited, the survivor's parent process id refers to
|
||||||
|
// a process that no longer exists, so it can only be found by image name. Deriving
|
||||||
|
// that name from the temporary directory keeps concurrent test runs from colliding.
|
||||||
|
const std::wstring survivorName = installation.GetPath().stem().wstring() + L"-survivor.exe";
|
||||||
|
const std::filesystem::path survivorPath = installation.GetPath() / survivorName;
|
||||||
|
|
||||||
|
CopyExecutable(GetShimUnderTest(), shimPath);
|
||||||
|
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
|
||||||
|
CopyExecutable(GetSystemDirectoryPath() / L"PING.EXE", survivorPath);
|
||||||
|
|
||||||
|
// START returns as soon as the survivor is running, so the CLI - and with it the shim -
|
||||||
|
// exits while the survivor is still alive.
|
||||||
|
const std::wstring arguments = LR"(/d /c start "" /b ")" + survivorPath.wstring() + LR"(" -n 30 127.0.0.1 > nul)";
|
||||||
|
Assert::AreEqual(static_cast<DWORD>(0), RunAndGetExitCode(shimPath, arguments), L"The target CLI did not succeed.");
|
||||||
|
|
||||||
|
const ProcessKiller survivor{ WaitForProcessByImageName(
|
||||||
|
survivorName,
|
||||||
|
std::nullopt,
|
||||||
|
SYNCHRONIZE | PROCESS_TERMINATE,
|
||||||
|
10'000) };
|
||||||
|
Assert::IsTrue(survivor.Get() != nullptr, L"The process started by the target CLI was killed with the shim.");
|
||||||
|
|
||||||
|
Assert::AreEqual(
|
||||||
|
static_cast<DWORD>(WAIT_TIMEOUT),
|
||||||
|
WaitForSingleObject(survivor.Get(), 1'000),
|
||||||
|
L"The process started by the target CLI did not outlive the shim.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
40
tools/CliShim/CliShim.rc
Normal file
40
tools/CliShim/CliShim.rc
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "../../src/common/version/version.h"
|
||||||
|
|
||||||
|
1 VERSIONINFO
|
||||||
|
FILEVERSION FILE_VERSION
|
||||||
|
PRODUCTVERSION FILE_VERSION
|
||||||
|
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||||
|
#ifdef _DEBUG
|
||||||
|
FILEFLAGS VS_FF_DEBUG
|
||||||
|
#else
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
#endif
|
||||||
|
FILEOS VOS_NT_WINDOWS32
|
||||||
|
FILETYPE VFT_APP
|
||||||
|
FILESUBTYPE VFT2_UNKNOWN
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904b0"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "Microsoft Corporation"
|
||||||
|
VALUE "FileDescription", "PowerToys CLI command shim"
|
||||||
|
VALUE "FileVersion", FILE_VERSION_STRING
|
||||||
|
VALUE "InternalName", "PowerToys.CliShim"
|
||||||
|
VALUE "LegalCopyright", "Copyright (C) Microsoft Corporation. All rights reserved."
|
||||||
|
VALUE "OriginalFilename", "PowerToys.CliShim.exe"
|
||||||
|
VALUE "ProductName", "PowerToys"
|
||||||
|
VALUE "ProductVersion", FILE_VERSION_STRING
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x409, 1200
|
||||||
|
END
|
||||||
|
END
|
||||||
83
tools/CliShim/CliShim.vcxproj
Normal file
83
tools/CliShim/CliShim.vcxproj
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup Label="NuGet">
|
||||||
|
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
|
||||||
|
<NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker>
|
||||||
|
<NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier>
|
||||||
|
<NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||||
|
<PropertyGroup Label="Globals">
|
||||||
|
<ProjectGuid>{8A7FB7FA-65EA-4004-BA73-1B237435A57B}</ProjectGuid>
|
||||||
|
<Keyword>Win32Proj</Keyword>
|
||||||
|
<RootNamespace>PowerToysCliShim</RootNamespace>
|
||||||
|
<ProjectName>PowerToys.CliShim</ProjectName>
|
||||||
|
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
|
||||||
|
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\CliShim\</OutDir>
|
||||||
|
<TargetName>PowerToys.CliShim</TargetName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Label="Configuration">
|
||||||
|
<ConfigurationType>Application</ConfigurationType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||||
|
<Import Project="CliShimManifest.props" />
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<VcpkgEnabled>false</VcpkgEnabled>
|
||||||
|
<VcpkgManifestEnabled>false</VcpkgManifestEnabled>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemDefinitionGroup>
|
||||||
|
<ClCompile>
|
||||||
|
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||||
|
<AdditionalIncludeDirectories>$(IntDir);%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<SubSystem>Console</SubSystem>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClCompile Include="main.cpp" />
|
||||||
|
<ClCompile Include="CommandLine.cpp" />
|
||||||
|
<ClInclude Include="CommandLine.h" />
|
||||||
|
<ResourceCompile Include="CliShim.rc" />
|
||||||
|
<None Include="CliShimManifest.props" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\common\version\version.vcxproj">
|
||||||
|
<Project>{CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}</Project>
|
||||||
|
<LinkLibraryDependencies>false</LinkLibraryDependencies>
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Keep the explicit WiX components synchronized with the shared manifest. This lives in the
|
||||||
|
product project rather than the wixproj so drift fails on any ordinary build instead of only
|
||||||
|
when someone builds the installer.
|
||||||
|
-->
|
||||||
|
<Target Name="ValidateCliShimInstallerManifest"
|
||||||
|
BeforeTargets="ClCompile"
|
||||||
|
Condition="'$(DesignTimeBuild)' != 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_CliShimWxsPath>$(RepoRoot)installer\PowerToysSetupVNext\CliShims.wxs</_CliShimWxsPath>
|
||||||
|
<_CliShimWxsText>$([System.IO.File]::ReadAllText('$(_CliShimWxsPath)'))</_CliShimWxsText>
|
||||||
|
<!-- Anchored to <File> so unrelated Name="*.exe" attributes cannot inflate the count. -->
|
||||||
|
<_CliShimWxsCount>$([System.Text.RegularExpressions.Regex]::Matches('$(_CliShimWxsText)', '<File\b[^>]*\bName="[^"]+\.exe"').Count)</_CliShimWxsCount>
|
||||||
|
<_CliShimManifestCount>@(CliShim->Count())</_CliShimManifestCount>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<CliShim>
|
||||||
|
<InWxs>$(_CliShimWxsText.Contains('Name="%(Identity).exe"'))</InWxs>
|
||||||
|
</CliShim>
|
||||||
|
</ItemGroup>
|
||||||
|
<Error Condition="'%(CliShim.InWxs)' != 'True'"
|
||||||
|
Text="CliShim installer drift: command '%(CliShim.Identity)' is missing from CliShims.wxs." />
|
||||||
|
<Error Condition="'$(_CliShimWxsCount)' != '$(_CliShimManifestCount)'"
|
||||||
|
Text="CliShim installer drift: CliShims.wxs installs $(_CliShimWxsCount) command executables, but CliShimManifest.props defines $(_CliShimManifestCount)." />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
47
tools/CliShim/CliShimManifest.props
Normal file
47
tools/CliShim/CliShimManifest.props
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (c) Microsoft Corporation. Licensed under the MIT license. -->
|
||||||
|
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<!-- Single source of truth for the runtime mapping and installed command names. -->
|
||||||
|
<!-- RelativeTarget is resolved from the installed bin folder and must use '/' separators. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<CliShim Include="PowerToys.FancyZones.CLI">
|
||||||
|
<RelativeTarget>../FancyZonesCLI.exe</RelativeTarget>
|
||||||
|
</CliShim>
|
||||||
|
<CliShim Include="PowerToys.ImageResizer.CLI">
|
||||||
|
<RelativeTarget>../WinUI3Apps/PowerToys.ImageResizerCLI.exe</RelativeTarget>
|
||||||
|
</CliShim>
|
||||||
|
<CliShim Include="PowerToys.FileLocksmith.CLI">
|
||||||
|
<RelativeTarget>../FileLocksmithCLI.exe</RelativeTarget>
|
||||||
|
</CliShim>
|
||||||
|
<CliShim Include="PowerToys.PowerDisplay.CLI">
|
||||||
|
<RelativeTarget>../WinUI3Apps/PowerToys.PowerDisplay.Cli.exe</RelativeTarget>
|
||||||
|
</CliShim>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Emits the mapping above as a C++ initializer list. The shim and its unit tests both import
|
||||||
|
this file, so the tests always assert against the same table the shim was built from and a
|
||||||
|
new command cannot be added to one without the other picking it up.
|
||||||
|
-->
|
||||||
|
<Target Name="GenerateCliShimTargets"
|
||||||
|
BeforeTargets="ClCompile"
|
||||||
|
Inputs="$(MSBuildProjectFullPath);$(MSBuildThisFileFullPath)"
|
||||||
|
Outputs="$(IntDir)CliShimTargets.g.inc">
|
||||||
|
<!--
|
||||||
|
RelativeTarget is emitted into a C++ string literal verbatim, so a Windows-style separator
|
||||||
|
becomes an escape sequence: "..\WinUI3Apps\x.exe" fails to compile as C4129 (promoted to an
|
||||||
|
error by TreatWarningAsError), and "..\bin\x.exe" compiles into control characters instead.
|
||||||
|
Both diagnostics would point at the generated file rather than at this one, so reject the
|
||||||
|
separator here, where the fix is.
|
||||||
|
-->
|
||||||
|
<Error Condition="$([System.String]::Copy('%(CliShim.RelativeTarget)').Contains('\'))"
|
||||||
|
Text="CliShim RelativeTarget must use '/' separators: '%(CliShim.Identity)' is mapped to '%(CliShim.RelativeTarget)'." />
|
||||||
|
<ItemGroup>
|
||||||
|
<_CliShimTargetLine Include="{ L%22%(CliShim.Identity)%22, L%22%(CliShim.RelativeTarget)%22 }," />
|
||||||
|
</ItemGroup>
|
||||||
|
<MakeDir Directories="$(IntDir)" />
|
||||||
|
<WriteLinesToFile File="$(IntDir)CliShimTargets.g.inc"
|
||||||
|
Lines="@(_CliShimTargetLine)"
|
||||||
|
Overwrite="true" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
63
tools/CliShim/CommandLine.cpp
Normal file
63
tools/CliShim/CommandLine.cpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#include "CommandLine.h"
|
||||||
|
|
||||||
|
namespace CommandLine
|
||||||
|
{
|
||||||
|
std::wstring StripArgumentZero(std::wstring_view commandLine)
|
||||||
|
{
|
||||||
|
const auto isWhitespace = [](const wchar_t character) {
|
||||||
|
return character == L' ' || character == L'\t';
|
||||||
|
};
|
||||||
|
|
||||||
|
size_t index = 0;
|
||||||
|
|
||||||
|
// A non-shell CreateProcessW caller can prepend whitespace; without this skip the scan
|
||||||
|
// below stalls at index 0 and leaks argv[0] into the forwarded tail. This is a deliberate
|
||||||
|
// departure from the CRT, which would report an empty argv[0] instead.
|
||||||
|
while (index < commandLine.size() && isWhitespace(commandLine[index]))
|
||||||
|
{
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// argv[0] is tokenized differently from every later argument, and the rule that matters is
|
||||||
|
// the CRT's, because that is what every target ends up parsing: FileLocksmithCLI is a
|
||||||
|
// native wmain, and the .NET CLIs receive their string[] from the apphost's wmain. The CRT
|
||||||
|
// (ucrt\startup\argv_parsing.cpp, parse_command_line) toggles an in-quotes flag on every
|
||||||
|
// quote while scanning argv[0] and ends the name at the first whitespace found outside
|
||||||
|
// quotes; a quote never terminates the name and a backslash never escapes one.
|
||||||
|
//
|
||||||
|
// CommandLineToArgvW is the odd one out - it ends a quoted argv[0] at the closing quote,
|
||||||
|
// with no toggling - so following it instead would leak the rest of the program name into
|
||||||
|
// the tail of a partially quoted command line such as
|
||||||
|
// `"%ProgramFiles%"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg`, which cmd.exe passes
|
||||||
|
// through verbatim. Matching the CRT is what makes the shim transparent: the target sees
|
||||||
|
// the exact tail it would have seen had the caller invoked it directly.
|
||||||
|
bool inQuotes = false;
|
||||||
|
while (index < commandLine.size())
|
||||||
|
{
|
||||||
|
const wchar_t character = commandLine[index];
|
||||||
|
++index;
|
||||||
|
|
||||||
|
if (character == L'"')
|
||||||
|
{
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inQuotes && isWhitespace(character))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < commandLine.size() && isWhitespace(commandLine[index]))
|
||||||
|
{
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::wstring{ commandLine.substr(index) };
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tools/CliShim/CommandLine.h
Normal file
19
tools/CliShim/CommandLine.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace CommandLine
|
||||||
|
{
|
||||||
|
// Removes argv[0] the way the CRT tokenizes it - every quote toggles an in-quotes flag and the
|
||||||
|
// name ends at the first whitespace outside quotes, so neither a quote nor a backslash-escape
|
||||||
|
// terminates it - then trims the separating spaces/tabs and preserves the remaining
|
||||||
|
// command-line text verbatim. Note that CommandLineToArgvW uses a different rule for argv[0];
|
||||||
|
// the CRT's is the one the target CLIs actually parse. Leading whitespace is skipped first,
|
||||||
|
// which the CRT does not do; see CommandLine.cpp.
|
||||||
|
std::wstring StripArgumentZero(std::wstring_view commandLine);
|
||||||
|
}
|
||||||
166
tools/CliShim/main.cpp
Normal file
166
tools/CliShim/main.cpp
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <Windows.h>
|
||||||
|
|
||||||
|
#include <wil/resource.h>
|
||||||
|
#include <wil/stl.h>
|
||||||
|
#include <wil/win32_helpers.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "CommandLine.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Match cmd.exe's "command not found" exit code for unmapped shim names.
|
||||||
|
constexpr int ExitCommandNotMapped = 9009;
|
||||||
|
|
||||||
|
// Shim failures use codes outside the range the target CLIs use for themselves (see
|
||||||
|
// doc/devdocs/cli-conventions.md: 0 success, 1 general error, 2 invalid arguments) so that a
|
||||||
|
// caller can tell "the shim could not run the CLI" apart from "the CLI ran and failed".
|
||||||
|
constexpr int ExitTargetNotFound = 9010;
|
||||||
|
constexpr int ExitLaunchFailed = 9011;
|
||||||
|
|
||||||
|
struct ShimTarget
|
||||||
|
{
|
||||||
|
const wchar_t* name;
|
||||||
|
const wchar_t* target;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generated from CliShimManifest.props.
|
||||||
|
constexpr ShimTarget ShimTargets[] = {
|
||||||
|
#include "CliShimTargets.g.inc"
|
||||||
|
};
|
||||||
|
|
||||||
|
// The child receives Ctrl+C/Break; keep the shim alive to return its exit code.
|
||||||
|
BOOL WINAPI ConsoleCtrlHandler(DWORD /*controlType*/)
|
||||||
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shim is the only handle a caller holds on the CLI, so killing the shim - taskkill without
|
||||||
|
// /T, Process.Kill() without entireProcessTree, a script's own timeout, stopping the debugger -
|
||||||
|
// must not leave the CLI running: PowerToys.FileLocksmith.CLI --wait polls until interrupted and
|
||||||
|
// prints nothing while it does. KILL_ON_JOB_CLOSE takes the CLI down with the shim, because the
|
||||||
|
// kernel closes the job handle however the shim dies.
|
||||||
|
//
|
||||||
|
// SILENT_BREAKAWAY_OK keeps the CLI's own children out of the job: PowerToys.FancyZones.CLI
|
||||||
|
// open-settings starts a long-lived PowerToys.exe and returns, and without this flag that window
|
||||||
|
// would be killed the moment the shim exits. Plain BREAKAWAY_OK cannot substitute - it requires
|
||||||
|
// the creator to pass CREATE_BREAKAWAY_FROM_JOB, which Process.Start cannot express. This is the
|
||||||
|
// one deliberate difference from src\runner\quick_access_host.cpp.
|
||||||
|
wil::unique_handle CreateShimJob()
|
||||||
|
{
|
||||||
|
wil::unique_handle job{ CreateJobObjectW(nullptr, nullptr) };
|
||||||
|
if (!job)
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||||
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK;
|
||||||
|
if (!SetInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
|
||||||
|
{
|
||||||
|
// A job without the kill limit buys nothing, so drop it rather than assign to it.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wchar_t* ResolveTarget(const std::wstring& commandName)
|
||||||
|
{
|
||||||
|
for (const ShimTarget& entry : ShimTargets)
|
||||||
|
{
|
||||||
|
if (CompareStringOrdinal(commandName.c_str(), -1, entry.name, -1, TRUE) == CSTR_EQUAL)
|
||||||
|
{
|
||||||
|
return entry.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int wmain()
|
||||||
|
{
|
||||||
|
SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE);
|
||||||
|
|
||||||
|
std::wstring modulePath;
|
||||||
|
if (FAILED(wil::GetModuleFileNameW(nullptr, modulePath)))
|
||||||
|
{
|
||||||
|
std::fwprintf(stderr, L"cli-shim: could not determine the shim's own path.\n");
|
||||||
|
return ExitLaunchFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::filesystem::path selfPath{ modulePath };
|
||||||
|
const std::wstring commandName = selfPath.stem().wstring();
|
||||||
|
|
||||||
|
const wchar_t* relativeTarget = ResolveTarget(commandName);
|
||||||
|
if (relativeTarget == nullptr)
|
||||||
|
{
|
||||||
|
std::fwprintf(stderr, L"cli-shim: no PowerToys CLI is mapped to the command '%s'.\n", commandName.c_str());
|
||||||
|
return ExitCommandNotMapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::filesystem::path targetPath = (selfPath.parent_path() / relativeTarget).lexically_normal();
|
||||||
|
|
||||||
|
std::error_code existsError;
|
||||||
|
if (!std::filesystem::exists(targetPath, existsError))
|
||||||
|
{
|
||||||
|
std::fwprintf(stderr, L"cli-shim: target not found: \"%s\".\n", targetPath.c_str());
|
||||||
|
return ExitTargetNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the raw tail so the caller's argument quoting remains unchanged.
|
||||||
|
const std::wstring forwardedArguments = CommandLine::StripArgumentZero(GetCommandLineW());
|
||||||
|
|
||||||
|
// lpApplicationName selects the target; argv[0] in the command line is cosmetic.
|
||||||
|
std::wstring commandLine = L'"' + targetPath.wstring() + L'"';
|
||||||
|
if (!forwardedArguments.empty())
|
||||||
|
{
|
||||||
|
commandLine.push_back(L' ');
|
||||||
|
commandLine.append(forwardedArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
STARTUPINFOW startupInfo{};
|
||||||
|
startupInfo.cb = sizeof(startupInfo);
|
||||||
|
wil::unique_process_information processInfo;
|
||||||
|
|
||||||
|
// Best effort, and silent on failure: an unprotected CLI beats a CLI that will not start, and
|
||||||
|
// this process's stderr belongs to the CLI's caller.
|
||||||
|
const wil::unique_handle shimJob = CreateShimJob();
|
||||||
|
|
||||||
|
if (!CreateProcessW(
|
||||||
|
targetPath.c_str(),
|
||||||
|
commandLine.data(), // Requires a mutable buffer; CreateProcessW may write to it.
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
TRUE, // Inherit handles: share stdin/stdout/stderr and stay in this console.
|
||||||
|
0,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
&startupInfo,
|
||||||
|
&processInfo))
|
||||||
|
{
|
||||||
|
std::fwprintf(stderr, L"cli-shim: failed to launch \"%s\" (error %lu).\n", targetPath.c_str(), GetLastError());
|
||||||
|
return ExitLaunchFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shimJob)
|
||||||
|
{
|
||||||
|
AssignProcessToJobObject(shimJob.get(), processInfo.hProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
WaitForSingleObject(processInfo.hProcess, INFINITE);
|
||||||
|
|
||||||
|
DWORD exitCode = static_cast<DWORD>(ExitLaunchFailed);
|
||||||
|
GetExitCodeProcess(processInfo.hProcess, &exitCode);
|
||||||
|
|
||||||
|
return static_cast<int>(exitCode);
|
||||||
|
}
|
||||||
@@ -383,6 +383,29 @@ try {
|
|||||||
RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration
|
RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cliShimPath = Join-Path $buildOutputPath 'CliShim\PowerToys.CliShim.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $cliShimPath -PathType Leaf)) {
|
||||||
|
Write-Error "CLI shim not found at '$cliShimPath'. Build PowerToys.slnx (tools\CliShim\CliShim.vcxproj) before building the installer."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nothing else validates the target half of the shim manifest: CliShim.vcxproj only compares
|
||||||
|
# command names against CliShims.wxs, and the launcher tests fabricate their target at whatever
|
||||||
|
# path the manifest declares, so they cannot falsify it. The target CLIs are harvested into the
|
||||||
|
# MSI by directory glob rather than authored by hand, so moving or renaming one leaves every
|
||||||
|
# other gate green and ships a shim that exits 9010 for every user. This is the first point where
|
||||||
|
# the whole product tree exists, so resolve each mapping here exactly the way the shim will at
|
||||||
|
# run time - relative to its own bin folder - against the layout the build output mirrors.
|
||||||
|
$cliShimBinDir = Join-Path $buildOutputPath 'bin'
|
||||||
|
$cliShimManifest = [xml](Get-Content -LiteralPath (Join-Path $repoRoot 'tools\CliShim\CliShimManifest.props') -Raw)
|
||||||
|
foreach ($shim in $cliShimManifest.Project.ItemGroup.CliShim) {
|
||||||
|
$resolvedTarget = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($cliShimBinDir, $shim.RelativeTarget))
|
||||||
|
if (-not (Test-Path -LiteralPath $resolvedTarget -PathType Leaf)) {
|
||||||
|
Write-Error "CLI shim '$($shim.Include)' maps to '$($shim.RelativeTarget)', which resolves to '$resolvedTarget' and does not exist. Update tools\CliShim\CliShimManifest.props to match where that CLI is built."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Set NUGET_PACKAGES environment variable if not set, to help wixproj find heat.exe
|
# Set NUGET_PACKAGES environment variable if not set, to help wixproj find heat.exe
|
||||||
if (-not $env:NUGET_PACKAGES) {
|
if (-not $env:NUGET_PACKAGES) {
|
||||||
$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE ".nuget\packages"
|
$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE ".nuget\packages"
|
||||||
|
|||||||
Reference in New Issue
Block a user