mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-29 16:36:40 +01:00
Compare commits
2 Commits
niels9001/
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b568e93dc | ||
|
|
4b1e0f6220 |
@@ -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
59
pt-cli-plan.md
Normal 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
358
pt-cli.md
Normal file
@@ -0,0 +1,358 @@
|
||||
选 Runner 作为唯一的 Server/Broker,ptcli 只是瘦客户端。
|
||||
|
||||
|
||||
模块通过各自的 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" 并返回 jobId;ptcli 可 ptcli job status <jobId> 轮询,或 Runner 通过同管道 增量推送 progress(JSON lines)。
|
||||
|
||||
|
||||
取消:ptcli 发送 { action: "cancel", jobId: "..." },Runner 调用模块 CancellationToken。
|
||||
|
||||
|
||||
|
||||
命令发现与帮助
|
||||
|
||||
|
||||
ptcli -m list:列出模块(Runner 直接返回 registry)。
|
||||
|
||||
|
||||
ptcli -m awake -h:DescribeCommands() 中的 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 子系统即可。
|
||||
|
||||
|
||||
IPC:NamedPipeServerStream + 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 个“历史事件触发点”接入 Router(Runner 内部去 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 服务,不必单独再起一个服务进程。
|
||||
|
||||
|
||||
|
||||
要的话,我可以按上面的协议给你一版 ptcli(C#)与 Runner 侧 CommandRouter 的“最小可用代码骨架”,再加上 Awake/Workspaces 的两个真实命令适配示例。
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
68
src/modules/interface/powertoy_cli.h
Normal file
68
src/modules/interface/powertoy_cli.h
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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
342
src/runner/cli_server.cpp
Normal 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
3
src/runner/cli_server.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void start_cli_server();
|
||||
144
src/runner/command_registry.cpp
Normal file
144
src/runner/command_registry.cpp
Normal 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;
|
||||
}
|
||||
42
src/runner/command_registry.h
Normal file
42
src/runner/command_registry.h
Normal 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_;
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
340
tools/ptcli/Program.cs
Normal 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
24
tools/ptcli/ptcli.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user