Files
PowerToys/tools/CliShim.UnitTests/LauncherIntegrationTests.cpp
moooyo ae416c045a 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
2026-08-14 14:32:27 +08:00

632 lines
23 KiB
C++

// 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.");
}
};
}