Compare commits

...

2 Commits

Author SHA1 Message Date
vanzue
2b568e93dc dev 2025-10-31 10:42:28 +08:00
vanzue
4b1e0f6220 cli 2025-10-30 22:49:16 +08:00
16 changed files with 1804 additions and 38 deletions

View File

@@ -74,6 +74,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRename.UnitTests", "sr
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ModuleTemplateCompileTest", "tools\project_template\ModuleTemplate\ModuleTemplateCompileTest.vcxproj", "{64A80062-4D8B-4229-8A38-DFA1D7497749}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ptcli", "tools\ptcli\ptcli.csproj", "{2589570C-B068-41CA-A554-BDCAE6FC4CAC}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManager", "src\modules\keyboardmanager\dll\KeyboardManager.vcxproj", "{89F34AF7-1C34-4A72-AA6E-534BCF972BD9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "imageresizer", "imageresizer", "{6C7F47CC-2151-44A3-A546-41C70025132C}"
@@ -926,6 +928,14 @@ Global
{64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|ARM64.Build.0 = Release|ARM64
{64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|x64.ActiveCfg = Release|x64
{64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|x64.Build.0 = Release|x64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Debug|ARM64.ActiveCfg = Debug|ARM64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Debug|ARM64.Build.0 = Debug|ARM64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Debug|x64.ActiveCfg = Debug|x64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Debug|x64.Build.0 = Debug|x64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Release|ARM64.ActiveCfg = Release|ARM64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Release|ARM64.Build.0 = Release|ARM64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Release|x64.ActiveCfg = Release|x64
{2589570C-B068-41CA-A554-BDCAE6FC4CAC}.Release|x64.Build.0 = Release|x64
{89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|ARM64.ActiveCfg = Debug|ARM64
{89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|ARM64.Build.0 = Debug|ARM64
{89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|x64.ActiveCfg = Debug|x64

59
pt-cli-plan.md Normal file
View File

@@ -0,0 +1,59 @@
# PowerToys CLI Implementation Plan
## Goal
- Deliver the `ptcli` command-line experience described in `pt-cli.md`, with `Runner` acting as the single broker for module commands.
- Provide a maintainable architecture where modules self-describe commands, and CLI clients consume a uniform JSON/NamedPipe protocol.
## Workstreams
### 1. Broker Foundation (Runner)
- **Command Registry**: Implement `IModuleCommandProvider` registration on module load and persist `CommandDescriptor` metadata (schema, elevation flag, long-running hints, docs).
- **IPC Host**: Stand up the `\\.\pipe\PowerToys.Runner.CLI` NamedPipe server; define request/response DTOs with versioning (`v` field, correlation IDs).
- **Dispatch Pipeline**: Validate module/action, apply schema validation, enforce elevation policy, and invoke `ExecuteAsync`.
- **Response Envelope**: Normalize `status` (`ok|error|accepted`), payload, and error block (`code/message/details`). Emit diagnostic logging (caller, command, latency, result).
### 2. CLI Thin Client (`ptcli`)
- **Argument Parsing**: Support `ptcli -m <module> <action> [--arg value]`, plus `--list-modules`, `--list-commands`.
- **Transport**: Serialize requests to JSON, connect to the pipe with timeout handling, and deserialize responses.
- **Output UX**: Map standard errors to friendly text, show structured results, and support optional `--json` passthrough.
- **Async Jobs**: Handle `status=accepted` by printing job IDs, exposing `ptcli job status <id>` and `ptcli job cancel <id>` commands (polling via Runner endpoints).
### 3. Module Onboarding
- **Awake**: Implement `IModuleCommandProvider` returning `set/start/stop/list` commands. Adapt current APIs or legacy triggers inside `ExecuteAsync`.
- **Workspaces**: Provide `list/apply/delete` commands; wrap existing workspace manager calls. Ensure long-running operations flag `LongRunning=true`.
- **Legacy Adapters**: For modules still using raw events/pipes, add Runner-side shims that translate command invocations while longer-term refactors are scheduled.
### 4. Capability Discovery & Help
- **Describe APIs**: Expose Runner endpoints for `modules`, `commands`, parameter schemas, and elevation requirements.
- **CLI Help**: Use discovery data to render `ptcli help`, module-specific usage, and argument hints without duplicating metadata.
### 5. Reliability, Security, Observability
- **Security**: Configure pipe DACL to restrict access to the interactive user; enforce argument length/type limits.
- **Concurrency**: Process each request on a dedicated task; delegate concurrency limits to modules. Provide cancellation tokens from Runner.
- **Tracing**: Emit structured logs/ETW for requests, errors, and long-running progress notifications.
- **Error Catalog**: Implement standardized error codes (`E_MODULE_NOT_FOUND`, `E_ARGS_INVALID`, `E_NEEDS_ELEVATION`, `E_TIMEOUT`, etc.) and map module exceptions accordingly.
### 6. Elevation & Policies
- **Elevation Flow**: Detect when commands require elevation; if Runner is not elevated, return `E_NEEDS_ELEVATION` with actionable hints. Integrate with existing elevated Runner helper when available.
- **Policy Hooks**: Add optional checks for policy/experiment gates before command execution.
### 7. Progress & Notifications
- **Progress Channel**: Support incremental JSON progress messages over the same pipe or via job polling endpoints.
- **Timeouts/Retry**: Implement configurable `timeoutMs` handling and `E_BUSY_RETRY` responses for transient module lock scenarios.
### 8. Incremental Rollout Strategy
- **Phase 1**: Ship Runner pipe host + CLI client with two flagship commands (Awake.Set, Workspaces.List); document manual enablement.
- **Phase 2**: Migrate additional modules through adapters; add help/describe surfaces and job management.
- **Phase 3**: Enforce schema validation, finalize error catalog, and wire observability dashboards.
- **Phase 4**: Deprecate direct module NamedPipe/event entry points once CLI parity is achieved.
### 9. Documentation & Maintenance
- **User Docs**: Populate `pt-cli.md` with usage examples, elevation guidance, and troubleshooting mapped to error codes.
- **Developer Guide**: Add module author instructions for implementing `IModuleCommandProvider`, including schema examples and best practices.
- **Release Checklist**: Track new commands per release, update discovery metadata, and ensure CLI integration tests cover regression cases.
## Open Questions
- What tooling will maintain JSON schemas (hand-authored vs. source-generated)?
- Should progress streaming use duplex pipe messages or a per-job polling API?
- How will elevated Runner lifecycle be managed (reuse existing helper vs. new broker)?
- Which modules are in-scope for the first public preview, and what is the rollout schedule?

358
pt-cli.md Normal file
View File

@@ -0,0 +1,358 @@
选 Runner 作为唯一的 Server/Brokerptcli 只是瘦客户端。
模块通过各自的 ModuleInterface 向 Runner 注册“可被调用的命令/参数模式”。
ptcli → Runner 用 统一的 IPC建议 NamedPipe + JSON-RPC/自定义轻量 JSON 协议)。
Runner 再把请求转发到对应模块(可以是直接调用模块公开的接口,或转译为该模块现有的触发机制,如 Event/NamedPipe
对“历史遗留的 event handle/pipe 触发点”,短期由 Runner 做兼容层;长期逐步统一为“命令接口”。
这样你能得到:能力发现、参数校验、权限/提权、错误码一致、可观察性一致、向后兼容。
组件与职责
ptcli瘦客户端
解析命令行ptcli -m awake set --duration 1h / ptcli -m workspace list
将其映射为通用消息JSON发给 Runner。
处理同步/异步返回、展示统一错误码与人类可读信息。
最多内置“列出模块/命令的帮助”这类“离线功能”,但真正的能力发现来自 Runner。
Runner统一 Server/Broker
启动时建立 NamedPipe 服务端:\\.\pipe\PowerToys.Runner.CLI示例
维护 Command Registry每个模块在加载/初始化时注册自己的命令(名称、参数 schema、是否需要提权、是否长任务、超时时间建议、描述文案等
收到请求后:
校验模块是否存在、命令是否存在、参数是否通过 schema 验证。
如需提权且当前 Runner 权限不足按策略返回“需要提权”的标准错误或通过你们现有的提权助手启动“Elevated Runner”做代办。
转发给目标模块(优先调用模块公开的“命令接口方法”;若模块尚未改造,由 Runner 适配为该模块现有触发Event/NamedPipe
汇总返回值,统一封装标准响应(状态、数据、错误码、诊断信息)。
Module实现者
实现 IModuleCommandProvider示例命名
IEnumerable<CommandDescriptor> DescribeCommands() 暴露命令元数据;
Task<CommandResult> ExecuteAsync(CommandInvocation ctx) 执行命令;
可标注“需要前台 UI”、“需要管理员”、“可能长时间运行支持取消/进度)”等。
现有“事件/NamedPipe 触发路径”的模块:短期由 Runner 适配;长期建议模块直接实现上面的 ExecuteAsync统一语义与可观测性。
协议与数据结构(建议)
请求ptcli→Runner
{
"v": 1,
"correlationId": "uuid",
"command": {
"module": "awake",
"action": "set", // 例如 set/start/stop/list 等
"args": { "duration": "1h" } // 按模块定义的 schema
},
"options": {
"timeoutMs": 20000,
"wantProgress": false
}
}
响应Runner→ptcli
{
"v": 1,
"correlationId": "uuid",
"status": "ok", // ok | error | accepted (异步)
"result": { /* 模块返回的结构化数据 */ },
"error": { // 仅当 status=error
"code": "E_NEEDS_ELEVATION", // 标准化错误码
"message": "Awake requires elevation",
"details": { "hint": "rerun with --elevated" }
}
}
进度/异步(可选)
长任务时status="accepted" 并返回 jobIdptcli 可 ptcli job status <jobId> 轮询,或 Runner 通过同管道 增量推送 progressJSON lines
取消ptcli 发送 { action: "cancel", jobId: "..." }Runner 调用模块 CancellationToken。
命令发现与帮助
ptcli -m list列出模块Runner 直接返回 registry
ptcli -m awake -hDescribeCommands() 中的 Awake 条目返回所有 action、参数与示例。
参数 schema用简化 JSON Schema或手写约束即可让 ptcli 能本地提示,也让 Runner 能服务器端校验。
示例映射
Awake
ptcli -m awake set --duration 1h
→ Awake.Set(duration=1h)
→ Runner 调用 AwakeModule.ExecuteAsync("set", args)
→ 结果:{ "effectiveUntil": "2025-10-30T18:00:00+08:00" }
ptcli -m awake stop
→ Awake.Stop()(幂等)
Workspaces
ptcli -m workspace list
→ Workspaces.List() 返回 { "items": [{ "id": "...", "name": "...", "monitors": 2, "windows": 14 }] }
ptcli -m workspace apply --id 123 --strict
→ Workspaces.Apply(id=123, strict=true) 支持进度与失败报告(缺失进程、权限不足等)。
Runner vs 直接敲模块事件/NamedPipe
直接敲模块(如你举的 EventWaitHandle优点
省一跳,模块自己掌控。
对少数“拍一下就够”的快捷触发点,写起来快。
缺点(关键)
入口分散:每个模块各有各的触发名、参数约定、错误语义。
能力发现困难ptcli 无法统一列出“模块能干啥、参数是什么”。
权限与多实例问题:有的模块需要管理员/前台,有的在用户会话,有的在服务;直接对模块打洞容易踩坑。
审计/可观察性差:难以统一日志/遥测/超时/取消。
演进成本高:接口一旦铺散,很难回收。
走 Runner Proxy推荐
统一注册:模块只跟 Runner 说“我能做哪些命令、参数是什么”。
统一协议ptcli 只会说一种“通用 JSON 命令”。
统一安全/提权/会话Runner 最懂自己所在的权限/桌面会话,可决定是否需要跳 Elevation/切用户会话。
兼容旧触发Runner 内部去“Set 事件/写管道”,外部对 ptcli 完全透明。
可测试/可监控:所有调用都经由同一 Broker便于打点、限流、诊断。
结论:把直接事件/管道触发视为“模块侧 private API”只由 Runner 调用。ptcli 与普通用户两边都只看得到 Runner 的“公共命令接口”。
Runner 怎么“轻量 Server”
进程:沿用现有 Runner不另起新守护新增一个 CommandRouter 子系统即可。
IPCNamedPipeServerStream + StreamJsonRpc或你们已有的 JSON 框架);单管道多请求(长度前缀 + correlationId
并发:每请求一个 Task模块执行受自身并发控制。
安全:给管道设定 DACL仅允许同一交互式用户或受信 SID连接参数白名单与长度限制防注入。
错误码:统一枚举(像 HTTP 状态一样):
E_MODULE_NOT_FOUND / E_COMMAND_NOT_FOUND / E_ARGS_INVALID
E_NEEDS_ELEVATION / E_ACCESS_DENIED
E_BUSY_RETRY / E_TIMEOUT / E_INTERNAL
最小可行落地(增量实施顺序)
在 Runner 加一个 Pipe + CommandRouter硬编码两个演示命令
Awake.Set(duration)(直接调用 Awake 的现有 API
Workspaces.List()(调用 Workspace 管理器)
写 ptcli只做 JSON 打包、发管道、打印结果。
给两个模块各加 IModuleCommandProvider从 Runner 注册。
把 1~2 个“历史事件触发点”接入 RouterRunner 内部去 Set Event对外暴露为 Module.Action。
扩展help/describe、Job/进度、取消、提权路径、返回码规范化。
简短示例C#,仅示意;注释英文)
Runner 接口定义
public record CommandDescriptor(
string Module, string Action, string Description,
IReadOnlyDictionary<string, ParamSpec> Params,
bool RequiresElevation = false, bool LongRunning = false);
public interface IModuleCommandProvider
{
IEnumerable<CommandDescriptor> DescribeCommands();
Task<CommandResult> ExecuteAsync(CommandInvocation ctx, CancellationToken ct);
}
public record CommandInvocation(string Action, IReadOnlyDictionary<string, object?> Args);
public record CommandResult(bool Ok, object? Data = null, string? ErrorCode = null, string? ErrorMessage = null);
Runner 注册与路由(伪码)
// On module load:
registry.Register(provider.DescribeCommands(), provider);
// On request:
var cmd = request.Command; // module, action, args
var provider = registry.Resolve(cmd.Module, cmd.Action);
ValidateArgs(cmd.Args, provider.Schema);
if (provider.RequiresElevation && !IsElevated())
return Error("E_NEEDS_ELEVATION", "Elevation required.");
return await provider.ExecuteAsync(new CommandInvocation(cmd.Action, cmd.Args), ct);
ptcli 调用(伪码)
// Build request from CLI args
var req = new { v = 1, correlationId = Guid.NewGuid(), command = new { module, action, args } };
using var client = new NamedPipeClientStream(".", "PowerToys.Runner.CLI", PipeDirection.InOut);
await client.ConnectAsync(timeout);
await WriteJsonAsync(client, req);
var resp = await ReadJsonAsync(client);
Render(resp);
return resp.status == "ok" ? 0 : 1;
兼容性与演进策略
不强行改造所有模块Runner 先用“适配器”把旧事件/管道触发包起来CLI 立即可用;后续模块逐步实现 IModuleCommandProvider。
版本化:请求/响应带 v便于以后扩展字段。
可观测性Runner 统一日志:调用者、命令、耗时、结果码;必要时加 ETW/Trace。
权限Runner 决策提权路径ptcli 接到 E_NEEDS_ELEVATION 时,可提示 --elevated 或弹系统 UAC按你们策略
并行/队列:对长任务(如 workspace apply可按模块内队列执行避免竞态CLI 侧提供 job status/cancel。
你关心的具体问题直答
“由 moduleinterface 做统一入口管理是个好主意吗?”
好,但入口管理应驻留在 Runner。模块通过 ModuleInterface 报告命令与执行入口Runner 统一对外暴露。
“ptcli 直接找模块执行,还是让 Runner 派发?”
让 Runner 派发。这样获得统一发现、权限、错误、日志与演进空间。
“现在很多模块用 named pipe/event 触发,这算绕过 Runner 吗?”
目前你看到的例子确实是“模块直连触发”。建议以后仅 Runner 使用这些内部触发(做兼容/过渡),对外统一成命令。
“Runner 起一个 light-weight server 吗?”
对。就在 Runner 里加一个轻量 NamedPipe JSON-RPC 服务,不必单独再起一个服务进程。
要的话,我可以按上面的协议给你一版 ptcliC#)与 Runner 侧 CommandRouter 的“最小可用代码骨架”,再加上 Awake/Workspaces 的两个真实命令适配示例。

View File

@@ -9,6 +9,7 @@
#include <common/utils/logger_helper.h>
#include <common/utils/resources.h>
#include <common/utils/winapi_error.h>
#include <common/utils/json.h>
#include <WorkspacesLib/trace.h>
#include <WorkspacesLib/WorkspacesData.h>
@@ -17,6 +18,9 @@
#include "resource.h"
#include <common/utils/EventWaiter.h>
#include <algorithm>
#include <cwctype>
#include <memory>
// Non-localizable
const std::wstring workspacesLauncherPath = L"PowerToys.WorkspacesLauncher.exe";
@@ -69,6 +73,8 @@ public:
return app_key.c_str();
}
pt::cli::IModuleCommandProvider* command_provider() override;
virtual std::optional<HotkeyEx> GetHotkeyEx() override
{
return m_hotkey;
@@ -359,9 +365,85 @@ private:
.modifiersMask = MOD_CONTROL | MOD_WIN,
.vkCode = 0xC0, // VK_OEM_3 key; usually `~
};
std::unique_ptr<pt::cli::IModuleCommandProvider> m_cliProvider;
pt::cli::CommandResult HandleList(const json::JsonObject& args) const;
};
class WorkspacesCommandProvider final : public pt::cli::IModuleCommandProvider
{
public:
explicit WorkspacesCommandProvider(const WorkspacesModuleInterface& owner) :
m_owner(owner)
{
}
std::wstring ModuleKey() const override
{
return L"workspaces";
}
std::vector<pt::cli::CommandDescriptor> DescribeCommands() const override
{
pt::cli::CommandDescriptor listDescriptor;
listDescriptor.action = L"list";
listDescriptor.description = L"List configured workspaces.";
return { std::move(listDescriptor) };
}
pt::cli::CommandResult Execute(const pt::cli::CommandInvocation& invocation) override
{
std::wstring action = invocation.action;
std::transform(action.begin(), action.end(), action.begin(), [](wchar_t ch) {
return static_cast<wchar_t>(std::towlower(ch));
});
if (action == L"list")
{
return m_owner.HandleList(invocation.args);
}
return pt::cli::CommandResult::Error(L"E_COMMAND_NOT_FOUND", L"Unsupported Workspaces command.");
}
private:
const WorkspacesModuleInterface& m_owner;
};
pt::cli::IModuleCommandProvider* WorkspacesModuleInterface::command_provider()
{
if (!m_cliProvider)
{
m_cliProvider = std::make_unique<WorkspacesCommandProvider>(*this);
}
return m_cliProvider.get();
}
pt::cli::CommandResult WorkspacesModuleInterface::HandleList(const json::JsonObject& args) const
{
UNREFERENCED_PARAMETER(args);
json::JsonObject payload = json::JsonObject();
auto workspacesPath = WorkspacesData::WorkspacesFile();
payload.SetNamedValue(L"path", json::value(workspacesPath));
auto stored = json::from_file(workspacesPath);
if (stored.has_value())
{
payload.SetNamedValue(L"data", json::value(*stored));
}
else
{
payload.SetNamedValue(L"data", json::value(json::JsonObject()));
}
return pt::cli::CommandResult::Success(std::move(payload));
}
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
{
return new WorkspacesModuleInterface();
}

View File

@@ -13,7 +13,13 @@
#include <common/utils/resources.h>
#include <common/utils/os-detect.h>
#include <common/utils/winapi_error.h>
#include <common/utils/json.h>
#include <algorithm>
#include <limits>
#include <memory>
#include <cwctype>
#include <optional>
#include <filesystem>
#include <set>
@@ -37,6 +43,105 @@ BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lp
const static wchar_t* MODULE_NAME = L"Awake";
const static wchar_t* MODULE_DESC = L"A module that keeps your computer awake on-demand.";
namespace
{
std::wstring to_lower_copy(std::wstring value)
{
std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) {
return static_cast<wchar_t>(std::towlower(ch));
});
return value;
}
std::wstring mode_to_string(int mode)
{
switch (mode)
{
case 0:
return L"passive";
case 1:
return L"indefinite";
case 2:
return L"timed";
case 3:
return L"expirable";
default:
return L"unknown";
}
}
std::optional<uint32_t> parse_duration_string(const std::wstring& raw)
{
if (raw.empty())
{
return std::nullopt;
}
std::wstring value = raw;
double multiplier = 1.0;
wchar_t suffix = value.back();
if (!iswdigit(suffix))
{
value.pop_back();
if (suffix == L'h' || suffix == L'H')
{
multiplier = 60.0;
}
else if (suffix == L'm' || suffix == L'M')
{
multiplier = 1.0;
}
else
{
return std::nullopt;
}
}
try
{
double numeric = std::stod(value);
if (numeric < 0)
{
return std::nullopt;
}
double totalMinutes = numeric * multiplier;
if (totalMinutes < 0 || totalMinutes > static_cast<double>(std::numeric_limits<uint32_t>::max()))
{
return std::nullopt;
}
return static_cast<uint32_t>(totalMinutes);
}
catch (...)
{
return std::nullopt;
}
}
std::optional<uint32_t> extract_duration_minutes(const json::JsonObject& args)
{
if (args.HasKey(L"durationMinutes"))
{
auto value = args.GetNamedNumber(L"durationMinutes");
if (value < 0)
{
return std::nullopt;
}
return static_cast<uint32_t>(value);
}
if (args.HasKey(L"duration"))
{
auto asString = args.GetNamedString(L"duration");
return parse_duration_string(asString.c_str());
}
return std::nullopt;
}
}
class Awake : public PowertoyModuleIface
{
std::wstring app_name;
@@ -45,6 +150,7 @@ class Awake : public PowertoyModuleIface
private:
bool m_enabled = false;
PROCESS_INFORMATION p_info = {};
std::unique_ptr<pt::cli::IModuleCommandProvider> m_cliProvider;
bool is_process_running()
{
@@ -176,9 +282,167 @@ public:
{
return m_enabled;
}
pt::cli::IModuleCommandProvider* command_provider() override;
pt::cli::CommandResult HandleStatus() const;
pt::cli::CommandResult HandleSet(const json::JsonObject& args);
};
class AwakeCommandProvider final : public pt::cli::IModuleCommandProvider
{
public:
explicit AwakeCommandProvider(Awake& owner) :
m_owner(owner)
{
}
std::wstring ModuleKey() const override
{
return L"awake";
}
std::vector<pt::cli::CommandDescriptor> DescribeCommands() const override
{
std::vector<pt::cli::CommandParameter> setParameters{
{ L"mode", false, L"Awake mode: passive | indefinite | timed." },
{ L"durationMinutes", false, L"Total duration in minutes for timed mode." },
{ L"duration", false, L"Duration with unit (e.g. 30m, 2h) for timed mode." },
{ L"displayOn", false, L"Whether to keep the display active (true/false)." },
};
pt::cli::CommandDescriptor setDescriptor;
setDescriptor.action = L"set";
setDescriptor.description = L"Configure the Awake module.";
setDescriptor.parameters = std::move(setParameters);
pt::cli::CommandDescriptor statusDescriptor;
statusDescriptor.action = L"status";
statusDescriptor.description = L"Inspect the current Awake mode.";
return { std::move(setDescriptor), std::move(statusDescriptor) };
}
pt::cli::CommandResult Execute(const pt::cli::CommandInvocation& invocation) override
{
auto action = to_lower_copy(invocation.action);
if (action == L"set")
{
return m_owner.HandleSet(invocation.args);
}
if (action == L"status")
{
return m_owner.HandleStatus();
}
return pt::cli::CommandResult::Error(L"E_COMMAND_NOT_FOUND", L"Unsupported Awake action.");
}
private:
Awake& m_owner;
};
pt::cli::IModuleCommandProvider* Awake::command_provider()
{
if (!m_cliProvider)
{
m_cliProvider = std::make_unique<AwakeCommandProvider>(*this);
}
return m_cliProvider.get();
}
pt::cli::CommandResult Awake::HandleStatus() const
{
auto settings = PTSettingsHelper::load_module_settings(app_key);
json::JsonObject payload = json::JsonObject();
if (!settings.HasKey(L"properties"))
{
payload.SetNamedValue(L"mode", json::value(L"unknown"));
payload.SetNamedValue(L"keepDisplayOn", json::value(false));
return pt::cli::CommandResult::Success(std::move(payload));
}
auto properties = settings.GetNamedObject(L"properties");
const auto modeValue = static_cast<int>(properties.GetNamedNumber(L"mode", 0));
payload.SetNamedValue(L"mode", json::value(mode_to_string(modeValue)));
payload.SetNamedValue(L"modeValue", json::value(modeValue));
payload.SetNamedValue(L"keepDisplayOn", json::value(properties.GetNamedBoolean(L"keepDisplayOn", false)));
payload.SetNamedValue(L"intervalHours", json::value(static_cast<uint32_t>(properties.GetNamedNumber(L"intervalHours", 0))));
payload.SetNamedValue(L"intervalMinutes", json::value(static_cast<uint32_t>(properties.GetNamedNumber(L"intervalMinutes", 0))));
if (properties.HasKey(L"expirationDateTime"))
{
payload.SetNamedValue(L"expirationDateTime", json::value(properties.GetNamedString(L"expirationDateTime")));
}
return pt::cli::CommandResult::Success(std::move(payload));
}
pt::cli::CommandResult Awake::HandleSet(const json::JsonObject& args)
{
std::wstring requestedMode = L"indefinite";
if (args.HasKey(L"mode"))
{
requestedMode = to_lower_copy(std::wstring(args.GetNamedString(L"mode").c_str()));
}
auto settings = PTSettingsHelper::load_module_settings(app_key);
json::JsonObject properties = settings.HasKey(L"properties") ? settings.GetNamedObject(L"properties") : json::JsonObject();
const bool keepDisplayOn = args.GetNamedBoolean(L"displayOn", properties.GetNamedBoolean(L"keepDisplayOn", false));
int modeValue = 1; // default to indefinite
if (requestedMode == L"passive")
{
modeValue = 0;
}
else if (requestedMode == L"indefinite" || requestedMode.empty())
{
modeValue = 1;
}
else if (requestedMode == L"timed")
{
modeValue = 2;
}
else
{
return pt::cli::CommandResult::Error(L"E_ARGS_INVALID", L"Unsupported mode. Use passive, indefinite, or timed.");
}
properties.SetNamedValue(L"keepDisplayOn", json::value(keepDisplayOn));
properties.SetNamedValue(L"mode", json::value(modeValue));
if (modeValue == 2)
{
auto durationMinutes = extract_duration_minutes(args);
if (!durationMinutes.has_value() || durationMinutes.value() == 0)
{
return pt::cli::CommandResult::Error(L"E_ARGS_INVALID", L"Timed mode requires a non-zero duration.");
}
const uint32_t totalMinutes = durationMinutes.value();
const uint32_t hours = totalMinutes / 60;
const uint32_t minutes = totalMinutes % 60;
properties.SetNamedValue(L"intervalHours", json::value(hours));
properties.SetNamedValue(L"intervalMinutes", json::value(minutes));
}
else
{
properties.SetNamedValue(L"intervalHours", json::value(0));
properties.SetNamedValue(L"intervalMinutes", json::value(0));
}
settings.SetNamedValue(L"properties", json::value(properties));
PTSettingsHelper::save_module_settings(app_key, settings);
return HandleStatus();
}
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
{
return new Awake();
}
}

View File

@@ -0,0 +1,68 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
#include <common/utils/json.h>
namespace pt::cli
{
struct CommandParameter
{
std::wstring name;
bool required = false;
std::wstring description;
};
struct CommandDescriptor
{
std::wstring action;
std::wstring description;
std::vector<CommandParameter> parameters;
bool requiresElevation = false;
bool longRunning = false;
};
struct CommandInvocation
{
std::wstring action;
json::JsonObject args;
};
struct CommandResult
{
bool ok = false;
json::JsonObject data;
std::wstring errorCode;
std::wstring errorMessage;
static CommandResult Success(json::JsonObject data = {});
static CommandResult Error(std::wstring code, std::wstring message);
};
inline CommandResult CommandResult::Success(json::JsonObject data)
{
CommandResult result;
result.ok = true;
result.data = std::move(data);
return result;
}
inline CommandResult CommandResult::Error(std::wstring code, std::wstring message)
{
CommandResult result;
result.ok = false;
result.errorCode = std::move(code);
result.errorMessage = std::move(message);
return result;
}
class IModuleCommandProvider
{
public:
virtual ~IModuleCommandProvider() = default;
virtual std::wstring ModuleKey() const = 0;
virtual std::vector<CommandDescriptor> DescribeCommands() const = 0;
virtual CommandResult Execute(const CommandInvocation& invocation) = 0;
};
}

View File

@@ -1,7 +1,8 @@
#pragma once
#include <compare>
#include <common/utils/gpo.h>
#include <compare>
#include <common/utils/gpo.h>
#include "powertoy_cli.h"
/*
DLL Interface for PowerToys. The powertoy_create() (see below) must return
@@ -140,20 +141,25 @@ public:
* milliseconds_win_key_must_be_pressed returns the number of milliseconds the win key should be pressed before triggering the module.
* Don't use these for new modules.
*/
virtual bool keep_track_of_pressed_win_key() { return false; }
virtual UINT milliseconds_win_key_must_be_pressed() { return 0; }
virtual void send_settings_telemetry()
{
}
virtual bool is_enabled_by_default() const { return true; }
/* Provides the GPO configuration value for the module. This should be overridden by the module interface to get the proper gpo policy setting. */
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration()
{
return powertoys_gpo::gpo_rule_configured_not_configured;
}
virtual bool keep_track_of_pressed_win_key() { return false; }
virtual UINT milliseconds_win_key_must_be_pressed() { return 0; }
virtual void send_settings_telemetry()
{
}
virtual bool is_enabled_by_default() const { return true; }
/* Provides the GPO configuration value for the module. This should be overridden by the module interface to get the proper gpo policy setting. */
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration()
{
return powertoys_gpo::gpo_rule_configured_not_configured;
}
virtual pt::cli::IModuleCommandProvider* command_provider()
{
return nullptr;
}
// Some actions like AdvancedPaste generate new inputs, which we don't want to catch again.
// The flag was purposefully chose to not collide with other keyboard manager flags.

342
src/runner/cli_server.cpp Normal file
View File

@@ -0,0 +1,342 @@
#include "pch.h"
#include "cli_server.h"
#include "command_registry.h"
#include <common/logger/logger.h>
#include <common/utils/json.h>
#include <atomic>
#include <thread>
#include <string>
#include <vector>
namespace
{
constexpr wchar_t PIPE_NAME[] = LR"(\\.\pipe\PowerToys.Runner.CLI)";
constexpr DWORD PIPE_BUFFER_SIZE = 64 * 1024;
std::once_flag startFlag;
std::atomic_bool running = false;
std::wstring utf8_to_wstring(const std::string& input)
{
if (input.empty())
{
return {};
}
int wideSize = MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), nullptr, 0);
if (wideSize <= 0)
{
return {};
}
std::wstring result(static_cast<size_t>(wideSize), L'\0');
MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), result.data(), wideSize);
return result;
}
std::string wstring_to_utf8(const std::wstring& input)
{
if (input.empty())
{
return {};
}
int narrowSize = WideCharToMultiByte(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), nullptr, 0, nullptr, nullptr);
if (narrowSize <= 0)
{
return {};
}
std::string result(static_cast<size_t>(narrowSize), '\0');
WideCharToMultiByte(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), result.data(), narrowSize, nullptr, nullptr);
return result;
}
bool read_message(HANDLE pipe, std::string& out)
{
char buffer[4096];
DWORD bytesRead = 0;
bool continueReading = true;
while (continueReading)
{
BOOL success = ReadFile(pipe, buffer, sizeof(buffer), &bytesRead, nullptr);
if (!success)
{
DWORD error = GetLastError();
if (error == ERROR_MORE_DATA)
{
out.append(buffer, buffer + bytesRead);
continue;
}
if (error != ERROR_BROKEN_PIPE && error != ERROR_PIPE_NOT_CONNECTED)
{
Logger::warn(L"CLI pipe read failed with error {}", error);
}
return false;
}
if (bytesRead > 0)
{
out.append(buffer, buffer + bytesRead);
}
continueReading = false;
}
return true;
}
json::JsonArray parameters_to_json(const std::vector<pt::cli::CommandParameter>& parameters)
{
json::JsonArray array;
for (const auto& parameter : parameters)
{
json::JsonObject node;
node.SetNamedValue(L"name", json::value(parameter.name));
node.SetNamedValue(L"required", json::value(parameter.required));
node.SetNamedValue(L"description", json::value(parameter.description));
array.Append(json::value(std::move(node)));
}
return array;
}
pt::cli::CommandResult handle_system_command(const std::wstring& action, const json::JsonObject& args)
{
if (action == L"list-modules")
{
auto snapshot = CommandRegistry::instance().snapshot();
json::JsonArray modules;
for (auto& moduleInfo : snapshot)
{
json::JsonObject moduleJson;
moduleJson.SetNamedValue(L"module", json::value(moduleInfo.moduleKey));
json::JsonArray commands;
for (const auto& descriptor : moduleInfo.commands)
{
json::JsonObject cmdJson;
cmdJson.SetNamedValue(L"action", json::value(descriptor.action));
cmdJson.SetNamedValue(L"description", json::value(descriptor.description));
cmdJson.SetNamedValue(L"requiresElevation", json::value(descriptor.requiresElevation));
cmdJson.SetNamedValue(L"longRunning", json::value(descriptor.longRunning));
cmdJson.SetNamedValue(L"parameters", json::value(parameters_to_json(descriptor.parameters)));
commands.Append(json::value(std::move(cmdJson)));
}
moduleJson.SetNamedValue(L"commands", json::value(std::move(commands)));
modules.Append(json::value(std::move(moduleJson)));
}
json::JsonObject payload;
payload.SetNamedValue(L"modules", json::value(std::move(modules)));
return pt::cli::CommandResult::Success(std::move(payload));
}
if (action == L"list-commands")
{
if (!args.HasKey(L"module"))
{
return pt::cli::CommandResult::Error(L"E_ARGS_INVALID", L"'module' argument is required.");
}
auto moduleName = std::wstring(args.GetNamedString(L"module").c_str());
auto reflection = CommandRegistry::instance().snapshot(moduleName);
if (!reflection.has_value())
{
return pt::cli::CommandResult::Error(L"E_MODULE_NOT_FOUND", L"Module not registered.");
}
json::JsonArray commands;
for (const auto& descriptor : reflection->commands)
{
json::JsonObject cmdJson;
cmdJson.SetNamedValue(L"action", json::value(descriptor.action));
cmdJson.SetNamedValue(L"description", json::value(descriptor.description));
cmdJson.SetNamedValue(L"requiresElevation", json::value(descriptor.requiresElevation));
cmdJson.SetNamedValue(L"longRunning", json::value(descriptor.longRunning));
cmdJson.SetNamedValue(L"parameters", json::value(parameters_to_json(descriptor.parameters)));
commands.Append(json::value(std::move(cmdJson)));
}
json::JsonObject payload;
payload.SetNamedValue(L"module", json::value(reflection->moduleKey));
payload.SetNamedValue(L"commands", json::value(std::move(commands)));
return pt::cli::CommandResult::Success(std::move(payload));
}
if (action == L"ping")
{
json::JsonObject payload;
payload.SetNamedValue(L"status", json::value(L"ok"));
return pt::cli::CommandResult::Success(std::move(payload));
}
return pt::cli::CommandResult::Error(L"E_COMMAND_NOT_FOUND", L"Unsupported system command.");
}
pt::cli::CommandResult dispatch_command(const std::wstring& module, const std::wstring& action, const json::JsonObject& args)
{
if (module == L"$system")
{
return handle_system_command(action, args);
}
pt::cli::CommandInvocation invocation{ action, args };
return CommandRegistry::instance().execute(module, invocation);
}
json::JsonObject build_error_payload(const std::wstring& code, const std::wstring& message)
{
json::JsonObject error;
error.SetNamedValue(L"code", json::value(code));
error.SetNamedValue(L"message", json::value(message));
return error;
}
void write_response(HANDLE pipe, const json::JsonObject& response)
{
auto serialized = response.Stringify();
auto utf8 = wstring_to_utf8(serialized.c_str());
DWORD bytesWritten = 0;
WriteFile(pipe, utf8.data(), static_cast<DWORD>(utf8.size()), &bytesWritten, nullptr);
FlushFileBuffers(pipe);
}
void handle_session(HANDLE pipe)
{
std::string rawRequest;
if (!read_message(pipe, rawRequest))
{
return;
}
json::JsonObject response;
response.SetNamedValue(L"v", json::value(1));
try
{
auto requestText = utf8_to_wstring(rawRequest);
if (requestText.empty())
{
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_INVALID", L"Empty request.")));
write_response(pipe, response);
return;
}
auto jsonValue = winrt::Windows::Data::Json::JsonValue::Parse(requestText);
auto root = jsonValue.GetObjectW();
auto correlationId = root.GetNamedString(L"correlationId", L"");
response.SetNamedValue(L"correlationId", json::value(correlationId));
if (!root.HasKey(L"command"))
{
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_INVALID", L"Missing command payload.")));
write_response(pipe, response);
return;
}
auto command = root.GetNamedObject(L"command");
const std::wstring module = std::wstring(command.GetNamedString(L"module", L"").c_str());
const std::wstring action = std::wstring(command.GetNamedString(L"action", L"").c_str());
json::JsonObject args = json::JsonObject();
if (command.HasKey(L"args"))
{
args = command.GetNamedObject(L"args");
}
if (module.empty() || action.empty())
{
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_ARGS_INVALID", L"'module' and 'action' must be provided.")));
write_response(pipe, response);
return;
}
auto result = dispatch_command(module, action, args);
if (result.ok)
{
response.SetNamedValue(L"status", json::value(L"ok"));
response.SetNamedValue(L"result", json::value(result.data));
}
else
{
response.SetNamedValue(L"status", json::value(L"error"));
auto errorCode = result.errorCode.empty() ? L"E_INTERNAL" : result.errorCode;
auto errorMsg = result.errorMessage.empty() ? L"Command failed." : result.errorMessage;
response.SetNamedValue(L"error", json::value(build_error_payload(errorCode, errorMsg)));
}
}
catch (const winrt::hresult_error&)
{
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_INVALID_JSON", L"Request payload was not valid JSON.")));
}
catch (const std::exception& ex)
{
Logger::error(L"CLI request processing threw: {}", winrt::to_hstring(ex.what()));
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_INTERNAL", L"Internal processing failure.")));
}
catch (...)
{
Logger::error(L"CLI request processing failed with unknown exception.");
response.SetNamedValue(L"status", json::value(L"error"));
response.SetNamedValue(L"error", json::value(build_error_payload(L"E_INTERNAL", L"Unknown processing failure.")));
}
write_response(pipe, response);
}
void server_loop()
{
while (running.load())
{
HANDLE pipe = CreateNamedPipeW(
PIPE_NAME,
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
PIPE_BUFFER_SIZE,
PIPE_BUFFER_SIZE,
0,
nullptr);
if (pipe == INVALID_HANDLE_VALUE)
{
DWORD error = GetLastError();
Logger::error(L"Failed to create CLI named pipe (error {}).", error);
Sleep(1000);
continue;
}
BOOL connected = ConnectNamedPipe(pipe, nullptr)
? TRUE
: (GetLastError() == ERROR_PIPE_CONNECTED);
if (connected)
{
handle_session(pipe);
}
FlushFileBuffers(pipe);
DisconnectNamedPipe(pipe);
CloseHandle(pipe);
}
}
}
void start_cli_server()
{
std::call_once(startFlag, [] {
running = true;
std::thread(server_loop).detach();
});
}

3
src/runner/cli_server.h Normal file
View File

@@ -0,0 +1,3 @@
#pragma once
void start_cli_server();

View File

@@ -0,0 +1,144 @@
#include "pch.h"
#include "command_registry.h"
#include <common/logger/logger.h>
#include <common/utils/elevation.h>
#include <algorithm>
#include <cwctype>
CommandRegistry& CommandRegistry::instance()
{
static CommandRegistry registry;
return registry;
}
void CommandRegistry::register_module(PowertoyModuleIface* module)
{
if (!module)
{
return;
}
auto provider = module->command_provider();
if (!provider)
{
return;
}
std::wstring moduleKey = provider->ModuleKey();
if (moduleKey.empty())
{
moduleKey = module->get_key();
}
auto descriptors = provider->DescribeCommands();
if (descriptors.empty())
{
return;
}
Entry entry{};
entry.provider = provider;
entry.moduleKey = moduleKey;
for (const auto& descriptor : descriptors)
{
auto normalizedAction = normalize_key(descriptor.action);
entry.descriptorsByAction.emplace(normalizedAction, descriptor);
}
std::unique_lock guard{ mutex_ };
entries_[normalize_key(moduleKey)] = std::move(entry);
}
pt::cli::CommandResult CommandRegistry::execute(const std::wstring& moduleKey, const pt::cli::CommandInvocation& invocation)
{
std::shared_lock guard{ mutex_ };
auto moduleIt = entries_.find(normalize_key(moduleKey));
if (moduleIt == entries_.end())
{
return pt::cli::CommandResult::Error(L"E_MODULE_NOT_FOUND", L"Module not registered for CLI use.");
}
auto& entry = moduleIt->second;
auto descriptorIt = entry.descriptorsByAction.find(normalize_key(invocation.action));
if (descriptorIt == entry.descriptorsByAction.end())
{
return pt::cli::CommandResult::Error(L"E_COMMAND_NOT_FOUND", L"Command not available for this module.");
}
const auto& descriptor = descriptorIt->second;
if (descriptor.requiresElevation && !is_process_elevated())
{
return pt::cli::CommandResult::Error(L"E_NEEDS_ELEVATION", L"This command requires elevation.");
}
auto provider = entry.provider;
guard.unlock();
try
{
return provider->Execute(invocation);
}
catch (const std::exception& ex)
{
Logger::error(L"CLI command execution failed: {}", winrt::to_hstring(ex.what()));
return pt::cli::CommandResult::Error(L"E_INTERNAL", L"Command execution failed due to an internal error.");
}
catch (...)
{
Logger::error(L"CLI command execution failed due to an unknown exception.");
return pt::cli::CommandResult::Error(L"E_INTERNAL", L"Command execution failed due to an unknown error.");
}
}
std::vector<CommandModuleReflection> CommandRegistry::snapshot() const
{
std::shared_lock guard{ mutex_ };
std::vector<CommandModuleReflection> result;
result.reserve(entries_.size());
for (const auto& [normalizedKey, entry] : entries_)
{
CommandModuleReflection reflection;
reflection.moduleKey = entry.moduleKey;
for (const auto& [actionKey, descriptor] : entry.descriptorsByAction)
{
reflection.commands.push_back(descriptor);
}
result.push_back(std::move(reflection));
}
return result;
}
std::optional<CommandModuleReflection> CommandRegistry::snapshot(const std::wstring& moduleKey) const
{
std::shared_lock guard{ mutex_ };
auto it = entries_.find(normalize_key(moduleKey));
if (it == entries_.end())
{
return std::nullopt;
}
CommandModuleReflection reflection;
reflection.moduleKey = it->second.moduleKey;
for (const auto& [actionKey, descriptor] : it->second.descriptorsByAction)
{
reflection.commands.push_back(descriptor);
}
return reflection;
}
std::wstring CommandRegistry::normalize_key(const std::wstring& value)
{
std::wstring normalized = value;
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](wchar_t ch) {
return static_cast<wchar_t>(std::towlower(ch));
});
return normalized;
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <modules/interface/powertoy_module_interface.h>
#include <modules/interface/powertoy_cli.h>
#include <common/utils/json.h>
#include <unordered_map>
#include <shared_mutex>
#include <vector>
#include <optional>
struct CommandModuleReflection
{
std::wstring moduleKey;
std::vector<pt::cli::CommandDescriptor> commands;
};
class CommandRegistry
{
public:
static CommandRegistry& instance();
void register_module(PowertoyModuleIface* module);
pt::cli::CommandResult execute(const std::wstring& moduleKey, const pt::cli::CommandInvocation& invocation);
std::vector<CommandModuleReflection> snapshot() const;
std::optional<CommandModuleReflection> snapshot(const std::wstring& moduleKey) const;
private:
struct Entry
{
pt::cli::IModuleCommandProvider* provider = nullptr;
std::wstring moduleKey;
std::unordered_map<std::wstring, pt::cli::CommandDescriptor> descriptorsByAction;
};
static std::wstring normalize_key(const std::wstring& value);
mutable std::shared_mutex mutex_;
std::unordered_map<std::wstring, Entry> entries_;
};

View File

@@ -28,7 +28,9 @@
#include <common/utils/clean_video_conference.h>
#include "UpdateUtils.h"
#include "ActionRunnerUtils.h"
#include "ActionRunnerUtils.h"
#include "command_registry.h"
#include "cli_server.h"
#include <winrt/Windows.System.h>
@@ -180,17 +182,22 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
L"PowerToys.LightSwitchModuleInterface.dll",
};
for (auto moduleSubdir : knownModules)
{
try
{
auto pt_module = load_powertoy(moduleSubdir);
modules().emplace(pt_module->get_key(), std::move(pt_module));
}
catch (...)
{
std::wstring errorMessage = POWER_TOYS_MODULE_LOAD_FAIL;
errorMessage += moduleSubdir;
for (auto moduleSubdir : knownModules)
{
try
{
auto ptModule = load_powertoy(moduleSubdir);
std::wstring moduleKey{ ptModule->get_key() };
auto [it, inserted] = modules().emplace(moduleKey, std::move(ptModule));
if (inserted)
{
CommandRegistry::instance().register_module(it->second.operator->());
}
}
catch (...)
{
std::wstring errorMessage = POWER_TOYS_MODULE_LOAD_FAIL;
errorMessage += moduleSubdir;
#ifdef _DEBUG
// In debug mode, simply log the warning and continue execution.
@@ -205,11 +212,12 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
MB_OK | MB_ICONERROR);
#endif
}
}
// Start initial powertoys
start_enabled_powertoys();
std::wstring product_version = get_product_version();
Trace::EventLaunch(product_version, isProcessElevated);
}
// Start initial powertoys
start_enabled_powertoys();
start_cli_server();
std::wstring product_version = get_product_version();
Trace::EventLaunch(product_version, isProcessElevated);
PTSettingsHelper::save_last_version_run(product_version);
if (openSettings)

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
<Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)\tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h runner.base.rc runner.rc" />
@@ -65,6 +65,8 @@
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="powertoy_module.cpp" />
<ClCompile Include="command_registry.cpp" />
<ClCompile Include="cli_server.cpp" />
<ClCompile Include="main.cpp" />
<ClCompile Include="restart_elevated.cpp" />
<ClCompile Include="centralized_kb_hook.cpp" />
@@ -86,6 +88,8 @@
<ClInclude Include="centralized_kb_hook.h" />
<ClInclude Include="settings_telemetry.h" />
<ClInclude Include="UpdateUtils.h" />
<ClInclude Include="command_registry.h" />
<ClInclude Include="cli_server.h" />
<ClInclude Include="powertoy_module.h" />
<ClInclude Include="resource.h" />
<ClInclude Include="restart_elevated.h" />
@@ -174,4 +178,4 @@
<Error Condition="!Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" />
<Error Condition="!Exists('..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
</Target>
</Project>
</Project>

View File

@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<ClCompile Include="command_registry.cpp">
<Filter>Utils</Filter>
</ClCompile>
<ClCompile Include="cli_server.cpp">
<Filter>Utils</Filter>
</ClCompile>
<ClCompile Include="main.cpp" />
<ClCompile Include="pch.cpp" />
<ClCompile Include="unhandled_exception_handler.cpp">
@@ -57,6 +63,12 @@
<ClInclude Include="tray_icon.h">
<Filter>Utils</Filter>
</ClInclude>
<ClInclude Include="command_registry.h">
<Filter>Utils</Filter>
</ClInclude>
<ClInclude Include="cli_server.h">
<Filter>Utils</Filter>
</ClInclude>
<ClInclude Include="powertoy_module.h">
<Filter>Utils</Filter>
</ClInclude>
@@ -132,4 +144,4 @@
<ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
</ItemGroup>
</Project>
</Project>

340
tools/ptcli/Program.cs Normal file
View File

@@ -0,0 +1,340 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Pipes;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace PowerToys.Cli
{
internal sealed class Program
{
private const string PipeName = "PowerToys.Runner.CLI";
private static readonly JsonSerializerOptions JsonOutputOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = null,
};
private static readonly JsonSerializerOptions JsonRequestOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = null,
};
[SupportedOSPlatform("windows")]
public static async Task<int> Main(string[] args)
{
if (args.Length == 0)
{
PrintHelp();
return 1;
}
bool listModules = false;
bool listCommands = false;
string? listCommandsModule = null;
string? module = null;
string? action = null;
bool rawJson = false;
int timeoutMs = 20000;
var payload = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < args.Length; i++)
{
var argument = args[i];
switch (argument)
{
case "--help":
case "-h":
PrintHelp();
return 0;
case "--list-modules":
listModules = true;
break;
case "--list-commands":
listCommands = true;
if (i + 1 < args.Length && !args[i + 1].StartsWith('-'))
{
listCommandsModule = args[++i];
}
break;
case "--module":
case "-m":
module = RequireValue(args, ref i, argument);
break;
case "--action":
case "-a":
action = RequireValue(args, ref i, argument);
break;
case "--json":
rawJson = true;
break;
case "--timeout":
var timeoutValue = RequireValue(args, ref i, argument);
if (!int.TryParse(timeoutValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out timeoutMs) || timeoutMs <= 0)
{
Console.Error.WriteLine("--timeout expects a positive integer value (milliseconds).");
return 1;
}
break;
default:
if (argument.StartsWith("--", StringComparison.Ordinal))
{
var key = argument.Substring(2);
var value = RequireValue(args, ref i, argument);
payload[key] = ParseValue(value);
}
else
{
Console.Error.WriteLine($"Unrecognized argument '{argument}'.");
PrintHelp();
return 1;
}
break;
}
}
try
{
if (listModules)
{
return await ExecuteCommandAsync(string.Empty, "list-modules", new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase), rawJson, timeoutMs);
}
if (listCommands)
{
if (string.IsNullOrWhiteSpace(listCommandsModule))
{
Console.Error.WriteLine("--list-commands requires a module name (e.g. ptcli --list-commands awake).");
return 1;
}
var argsPayload = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
["module"] = listCommandsModule!,
};
return await ExecuteCommandAsync(string.Empty, "list-commands", argsPayload, rawJson, timeoutMs);
}
if (string.IsNullOrWhiteSpace(module) || string.IsNullOrWhiteSpace(action))
{
Console.Error.WriteLine("Both --module and --action must be specified.");
PrintHelp();
return 1;
}
return await ExecuteCommandAsync(module!, action!, payload, rawJson, timeoutMs);
}
catch (TimeoutException)
{
Console.Error.WriteLine("Timed out while communicating with the PowerToys runner.");
return 1;
}
catch (IOException ex)
{
Console.Error.WriteLine($"Pipe communication failed: {ex.Message}");
return 1;
}
}
private static string RequireValue(string[] args, ref int index, string option)
{
if (index + 1 >= args.Length)
{
Console.Error.WriteLine($"Option {option} requires a value.");
Environment.Exit(1);
}
return args[++index];
}
private static object ParseValue(string value)
{
if (bool.TryParse(value, out var boolValue))
{
return boolValue;
}
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
return intValue;
}
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleValue))
{
return doubleValue;
}
return value;
}
[SupportedOSPlatform("windows")]
private static async Task<int> ExecuteCommandAsync(string module, string action, Dictionary<string, object> args, bool rawJson, int timeoutMs)
{
var request = new
{
v = 1,
correlationId = Guid.NewGuid().ToString(),
command = new
{
module,
action,
args,
},
options = new
{
timeoutMs,
wantProgress = false,
},
};
string payload = JsonSerializer.Serialize(request, JsonRequestOptions);
using var client = new NamedPipeClientStream(
".",
PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await client.ConnectAsync(timeoutMs).ConfigureAwait(false);
client.ReadMode = PipeTransmissionMode.Message;
using (var writer = new StreamWriter(client, Encoding.UTF8, leaveOpen: true))
{
await writer.WriteAsync(payload).ConfigureAwait(false);
await writer.FlushAsync().ConfigureAwait(false);
}
client.WaitForPipeDrain();
var responseMessage = await ReadMessageAsync(client).ConfigureAwait(false);
if (string.IsNullOrEmpty(responseMessage))
{
Console.Error.WriteLine("Received empty response from the PowerToys runner.");
return 1;
}
var document = JsonSerializer.Deserialize<JsonElement>(responseMessage);
var status = document.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : "error";
if (rawJson)
{
Console.WriteLine(JsonSerializer.Serialize(document, JsonOutputOptions));
return string.Equals(status, "ok", StringComparison.OrdinalIgnoreCase) ? 0 : 1;
}
if (string.Equals(status, "ok", StringComparison.OrdinalIgnoreCase))
{
if (document.TryGetProperty("result", out var resultElement))
{
RenderResult(resultElement);
}
else
{
Console.WriteLine("Command completed successfully.");
}
return 0;
}
if (document.TryGetProperty("error", out var errorElement))
{
var code = errorElement.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : "E_UNKNOWN";
var message = errorElement.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : "Command failed.";
Console.Error.WriteLine($"{code}: {message}");
}
else
{
Console.Error.WriteLine("Command failed.");
}
return 1;
}
private static async Task<string?> ReadMessageAsync(NamedPipeClientStream client)
{
var builder = new StringBuilder();
using var reader = new StreamReader(client, Encoding.UTF8, false, bufferSize: 1024, leaveOpen: true);
char[] buffer = new char[1024];
int read;
while ((read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
builder.Append(buffer, 0, read);
}
return builder.ToString();
}
private static void RenderResult(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty("modules", out var modulesElement) && modulesElement.ValueKind == JsonValueKind.Array)
{
foreach (var module in modulesElement.EnumerateArray())
{
var name = module.TryGetProperty("module", out var moduleName) ? moduleName.GetString() : "<module>";
Console.WriteLine(name);
if (module.TryGetProperty("commands", out var commandsElement) && commandsElement.ValueKind == JsonValueKind.Array)
{
foreach (var command in commandsElement.EnumerateArray())
{
var action = command.TryGetProperty("action", out var actionElement) ? actionElement.GetString() : "<action>";
var description = command.TryGetProperty("description", out var descriptionElement) ? descriptionElement.GetString() : string.Empty;
Console.WriteLine($" - {action}: {description}");
}
}
}
return;
}
if (element.TryGetProperty("commands", out var commands) && commands.ValueKind == JsonValueKind.Array)
{
var moduleName = element.TryGetProperty("module", out var moduleElement) ? moduleElement.GetString() : "<module>";
Console.WriteLine(moduleName);
foreach (var command in commands.EnumerateArray())
{
var action = command.TryGetProperty("action", out var actionElement) ? actionElement.GetString() : "<action>";
var description = command.TryGetProperty("description", out var descriptionElement) ? descriptionElement.GetString() : string.Empty;
Console.WriteLine($" - {action}: {description}");
}
return;
}
}
Console.WriteLine(JsonSerializer.Serialize(element, JsonOutputOptions));
}
private static void PrintHelp()
{
Console.WriteLine("PowerToys CLI");
Console.WriteLine();
Console.WriteLine("Usage:");
Console.WriteLine(" ptcli --list-modules");
Console.WriteLine(" ptcli --list-commands <module>");
Console.WriteLine(" ptcli -m <module> -a <action> [--key value] [--json]");
Console.WriteLine();
Console.WriteLine("Examples:");
Console.WriteLine(" ptcli --list-modules");
Console.WriteLine(" ptcli --list-commands awake");
Console.WriteLine(" ptcli -m awake -a status");
Console.WriteLine(" ptcli -m awake -a set --mode timed --duration 30m --displayOn true");
}
}
}

24
tools/ptcli/ptcli.csproj Normal file
View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common settings as well -->
<Import Project="..\..\src\Common.SelfContained.props" />
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<ProjectGuid>{2589570C-B068-41CA-A554-BDCAE6FC4CAC}</ProjectGuid>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>PowerToys.Cli</AssemblyName>
<RootNamespace>PowerToys.Cli</RootNamespace>
<OutputPath>..\..\$(Platform)\$(Configuration)\ptcli\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Platform)'=='x64'">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup Condition="'$(Platform)'=='ARM64'">
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
</PropertyGroup>
</Project>