mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +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:
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
|
||||
}
|
||||
|
||||
$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
|
||||
if (-not $env:NUGET_PACKAGES) {
|
||||
$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE ".nuget\packages"
|
||||
|
||||
Reference in New Issue
Block a user