目录 Demo 插件 Project 项目 Visual Studio CLI 命令行 dotnet new Metadata 元数据 Main Interfaces 接口 IPlugin IPluginI18n IDelayedExecutionPlugin IContextMenu ISettingProvider Classes 类 PluginInitContext Query Result ContextMenuResult Actions 操作 Query 查询小插件代码示例 Logging 日志 Dependencies 第三方依赖 Tests 测试 Distribution 发布 Linting 代码检查工具 总结 参考资料
前段时间突然发现 PowerToys Run 是支持第三方开发插件 的,但对于如何开发有关插件互联网上资料较少,官方也没有提供正式的文档,所以这边将基于 hlaueriksson 的 《Creating custom PowerToys Run plugins》 这篇教学文章进行翻译,加上最近自己开发 PowerToys Run 插件的一些理解,希望有更多同学参与第三方插件开发,丰富 PowerToys Run 的相关生态。
由于开发的资料相对缺乏,除了本篇文章,学习开发的最好方式还是直接阅读官方 PowerToys Run 插件的源码: https://github.com/microsoft/PowerToys/tree/main/src/modules/launcher/Plugins ,当自己开发的时候可以去找实现了相似特性的插件源码,这样能快速掌握一些接口以及库的细节。
注意 PowerToys Run 的插件目前似乎只支持 .Net 开发(C#),不像其他 Launcher 工具比如 alfred 、 wox 等支持比较多的编程语言特别是脚本语言。不过由于插件实现其实都很简单,即便没学过 C#/.Net 我想照着其他项目源码然后结合 Claude/ChatGPT 来辅助编程应该也能够实现自己想要的插件。
Demo 插件 为了便于入门,hlaueriksson 在文章中展示了一个 Demo 插件,这个插件用来记录输入的单词数(word)与字符数(characters):
这个插件触发的关键字(ActionKeyword
)是 “demo”,同时这个插件还支持简单的配置:
Count spaces: true
| false
,配置是否把空格纳入计数的范围
插件虽然简单,但是涉及到的功能点是比较全面的: Query 查询的实现、Config 配置选项的实现、以及查询后出现条目对应的菜单项……所以了解这个 Demo 的实现基本可以知道怎么写一个基本的 PowerToys Run 插件了。这个 Demo 插件项目的源代码在: https://github.com/hlaueriksson/ConductOfCode/tree/master/PowerToysRun
Project 项目 在开始自己的项目之前,首先我们需要看下官方提供的新插件研发的 Checklist:
这个清单的关键点主要是以下几条:
项目名称遵循: Community.PowerToys.Run.Plugin.<PluginName>
.Net 配置目标框架(Target Framework): net8.0-windows
创建一个 Main
类,对应文件 Main.cs
创建 plugin.json
插件配置文件
下面我们围绕 Visual Studio 以及命令行等开发环境介绍下如何创建我们的项目脚手架
Visual Studio 在 Visual Studio 我们会介绍更多创建 PowerToys Run 插件项目的所有细节,首先我们在 Visual Studio 创建 Class Library 类库项目。
然后我们按照下面的例子来编辑我们的 .csproj
项目文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 <Project Sdk ="Microsoft.NET.Sdk" > <PropertyGroup > <TargetFramework > net8.0-windows</TargetFramework > <Platforms > x64;ARM64</Platforms > <PlatformTarget > $(Platform)</PlatformTarget > <UseWPF > true</UseWPF > </PropertyGroup > <PropertyGroup > <LangVersion > preview</LangVersion > <ImplicitUsings > enable</ImplicitUsings > <Nullable > enable</Nullable > </PropertyGroup > <ItemGroup Condition ="'$(Platform)' == 'x64'" > <Reference Include ="..\libs\x64\PowerToys.Common.UI.dll" /> <Reference Include ="..\libs\x64\PowerToys.ManagedCommon.dll" /> <Reference Include ="..\libs\x64\PowerToys.Settings.UI.Lib.dll" /> <Reference Include ="..\libs\x64\Wox.Infrastructure.dll" /> <Reference Include ="..\libs\x64\Wox.Plugin.dll" /> </ItemGroup > <ItemGroup Condition ="'$(Platform)' == 'ARM64'" > <Reference Include ="..\libs\ARM64\PowerToys.Common.UI.dll" /> <Reference Include ="..\libs\ARM64\PowerToys.ManagedCommon.dll" /> <Reference Include ="..\libs\ARM64\PowerToys.Settings.UI.Lib.dll" /> <Reference Include ="..\libs\ARM64\Wox.Infrastructure.dll" /> <Reference Include ="..\libs\ARM64\Wox.Plugin.dll" /> </ItemGroup > <ItemGroup > <None Include ="plugin.json" > <CopyToOutputDirectory > PreserveNewest</CopyToOutputDirectory > </None > <None Include ="Images\*.png" > <CopyToOutputDirectory > PreserveNewest</CopyToOutputDirectory > </None > </ItemGroup > </Project >
这里几个重点:
Platforms 平台:x64
与 ARM64
配置 UseWPF
以包含 WPF 的库引用
依赖:PowerToys 与 Wox 的 .dll
库
在上面 .csproj
文件中引用的 .dll
文件是所需依赖项的示例,具体取决于你的插件需要支持的功能。
然而,由于上面这些 dll 文件没有对应 Nuget 包的官方封装,所以可能需要自己编译 ,或者在 PowerToys 项目中直接引用 Dll 文件,像 Lin Yu-Chieh 的 EverythingPowerToys 插件一样。
而 hlaueriksson 直接创建了一个 Nuget 包将上述 PowerToys Run Plugin 依赖打包在了一起:
当使用 Community.PowerToys.Run.Plugin.Dependencies
时,.csproj
文件就变成了这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <Project Sdk ="Microsoft.NET.Sdk" > <PropertyGroup > <TargetFramework > net8.0-windows</TargetFramework > <Platforms > x64;ARM64</Platforms > <PlatformTarget > $(Platform)</PlatformTarget > <UseWPF > true</UseWPF > </PropertyGroup > <PropertyGroup > <LangVersion > preview</LangVersion > <ImplicitUsings > enable</ImplicitUsings > <Nullable > enable</Nullable > </PropertyGroup > <ItemGroup > <PackageReference Include ="Community.PowerToys.Run.Plugin.Dependencies" Version ="0.84.1" /> </ItemGroup > <ItemGroup > <None Include ="plugin.json" > <CopyToOutputDirectory > PreserveNewest</CopyToOutputDirectory > </None > <None Include ="Images\*.png" > <CopyToOutputDirectory > PreserveNewest</CopyToOutputDirectory > </None > </ItemGroup > </Project >
CLI 命令行 dotnet new
更方便的方式是使用 hlaueriksson 搭建的 PowerToys Run 插件 .Net 项目脚手架模板: https://github.com/hlaueriksson/Community.PowerToys.Run.Plugin.Templates
我们可以使用 dotnet new install Community.PowerToys.Run.Plugin.Templates
安装这个模板,后面当我们想要创建新的 PowerToys Run 插件项目时,只需要利用 dotnet new
命令即可创建:
当然各类 IDE(如 Visual Studio、Rider 等)对于 dotnet new
都有充分的支持,可以自行选择基于 IDE 功能还是自己手动命令行输入创建。dotnet new
运行完后就会生成一个解决方案或项目的脚手架,最主要需要关心的是项目已经为我们创建好的这几部分内容:
Images/*.png
-Icon 图标图片,一般需要对应 Windows 暗色与亮色主题两版图片
Main.cs
-整体插件代码逻辑的入口点
plugin.json
- 插件元数据
项目的 plugin.json
元数据文件应该是像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "ID" : "AE953C974C2241878F282EA18A7769E4" , "ActionKeyword" : "demo" , "IsGlobal" : false , "Name" : "Demo" , "Author" : "hlaueriksson" , "Version" : "1.0.0" , "Language" : "csharp" , "Website" : "https://github.com/hlaueriksson/ConductOfCode" , "ExecuteFileName" : "Community.PowerToys.Run.Plugin.Demo.dll" , "IcoPathDark" : "Images\\demo.dark.png" , "IcoPathLight" : "Images\\demo.light.png" , "DynamicLoading" : false }
具体每个字段规定的类型以及含义在 New plugin checklist 可以查到。
Main Main.cs
包含是整体插件代码逻辑的入口,相当于 main 函数。以 Demo 插件的 Main.cs
为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 using System.Windows;using System.Windows.Controls;using System.Windows.Input;using ManagedCommon;using Microsoft.PowerToys.Settings.UI.Library;using Wox.Plugin;using Wox.Plugin.Logger;namespace Community.PowerToys.Run.Plugin.Demo { public class Main : IPlugin , IContextMenu , ISettingProvider , IDisposable { public static string PluginID => "AE953C974C2241878F282EA18A7769E4" ; public string Name => "Demo" ; public string Description => "Count words and characters in text" ; public IEnumerable<PluginAdditionalOption> AdditionalOptions => [ new () { Key = nameof (CountSpaces), DisplayLabel = "Count spaces" , DisplayDescription = "Count spaces as characters" , PluginOptionType = PluginAdditionalOption.AdditionalOptionType.Checkbox, Value = CountSpaces, } ]; private bool CountSpaces { get ; set ; } private PluginInitContext? Context { get ; set ; } private string ? IconPath { get ; set ; } private bool Disposed { get ; set ; } public List<Result> Query (Query query ) { Log.Info("Query: " + query.Search, GetType()); var words = query.Terms.Count; var transcription = TimeSpan.FromMinutes(words / 32.5 ); var minutes = $"{(int )transcription.TotalMinutes} :{transcription.Seconds:00 } " ; var charactersWithSpaces = query.Search.Length; var charactersWithoutSpaces = query.Terms.Sum(x => x.Length); return [ new () { QueryTextDisplay = query.Search, IcoPath = IconPath, Title = $"Words: {words} " , SubTitle = $"Transcription: {minutes} minutes" , ToolTipData = new ToolTipData("Words" , $"{words} words\n{minutes} minutes for transcription\nAverage rate for transcription: 32.5 words per minute" ), ContextData = (words, transcription), }, new () { QueryTextDisplay = query.Search, IcoPath = IconPath, Title = $"Characters: {(CountSpaces ? charactersWithSpaces : charactersWithoutSpaces)} " , SubTitle = CountSpaces ? "With spaces" : "Without spaces" , ToolTipData = new ToolTipData("Characters" , $"{charactersWithSpaces} characters (with spaces)\n{charactersWithoutSpaces} characters (without spaces)" ), ContextData = CountSpaces ? charactersWithSpaces : charactersWithoutSpaces, }, ]; } public void Init (PluginInitContext context ) { Log.Info("Init" , GetType()); Context = context ?? throw new ArgumentNullException(nameof (context)); Context.API.ThemeChanged += OnThemeChanged; UpdateIconPath(Context.API.GetCurrentTheme()); } public List<ContextMenuResult> LoadContextMenus (Result selectedResult ) { Log.Info("LoadContextMenus" , GetType()); if (selectedResult?.ContextData is (int words, TimeSpan transcription )) { return [ new ContextMenuResult { PluginName = Name, Title = "Copy (Enter)" , FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets" , Glyph = "\xE8C8" , AcceleratorKey = Key.Enter, Action = _ => CopyToClipboard(words.ToString()), }, new ContextMenuResult { PluginName = Name, Title = "Copy time (Ctrl+Enter)" , FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets" , Glyph = "\xE916" , AcceleratorKey = Key.Enter, AcceleratorModifiers = ModifierKeys.Control, Action = _ => CopyToClipboard(transcription.ToString()), }, ]; } if (selectedResult?.ContextData is int characters) { return [ new ContextMenuResult { PluginName = Name, Title = "Copy (Enter)" , FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets" , Glyph = "\xE8C8" , AcceleratorKey = Key.Enter, Action = _ => CopyToClipboard(characters.ToString()), }, ]; } return []; } public Control CreateSettingPanel () => throw new NotImplementedException(); public void UpdateSettings (PowerLauncherPluginSettings settings ) { Log.Info("UpdateSettings" , GetType()); CountSpaces = settings.AdditionalOptions.SingleOrDefault(x => x.Key == nameof (CountSpaces))?.Value ?? false ; } public void Dispose () { Log.Info("Dispose" , GetType()); Dispose(true ); GC.SuppressFinalize(this ); } protected virtual void Dispose (bool disposing ) { if (Disposed || !disposing) { return ; } if (Context?.API != null ) { Context.API.ThemeChanged -= OnThemeChanged; } Disposed = true ; } private void UpdateIconPath (Theme theme ) => IconPath = theme == Theme.Light || theme == Theme.HighContrastWhite ? Context?.CurrentPluginMetadata.IcoPathLight : Context?.CurrentPluginMetadata.IcoPathDark; private void OnThemeChanged (Theme currentTheme, Theme newTheme ) => UpdateIconPath(newTheme); private static bool CopyToClipboard (string ? value ) { if (value != null ) { Clipboard.SetText(value ); } return true ; } } }
这里面关键的类、接口,在后文会统一介绍。
Interfaces 接口 插件需要实现的关键接口似乎都来自 Wox.Plugin
,虽然说是在开发 PowerToys Run 插件但好像还是遵循 Wox 扩展开发的类与接口的实践(x
需要关心的主要是这些接口:
IPlugin
IPluginI18n
IDelayedExecutionPlugin
IContextMenu
ISettingProvider
我们在 Main.cs
的 Main 类需要按需实现这些接口。
IPlugin 最重要的接口就是 IPlugin
:
1 2 3 4 5 6 7 8 9 10 public interface IPlugin { List<Result> Query (Query query ) ; void Init (PluginInitContext context ) ; string Name { get ; } string Description { get ; } }
Query
- 查询方法,执行主要代码逻辑的方法,当用户在 PowerToys Run 的搜索框中输入字符时插件的 Query
方法就会被调用,作为入参的 query 对象会包含用户输入的文本、唤醒关键字(ActionKeyWord
)等信息,我们可以在 Query
方法里根据我们的需要基于用户的输入进行各种处理。
Init
- 插件的初始化方法,入参有一个 PluginInitContext
,我们可以获取到插件的对应上下文,并注册一些关心的事件。
Name
- 应该与 plugin.json
中的 name 值一致,但这个 Name
是可以做 i18n 本地化的。
IPluginI18n 如果需要做 i18n 国际化就需要实现这个 IPluginI18n
接口:
1 2 3 4 5 6 public interface IPluginI18n { string GetTranslatedPluginTitle () ; string GetTranslatedPluginDescription () ; }
但目前第三方插件不知道为何似乎没法实现 i18n 国际化,即便按照官方插件的方案来做也无法生成对应语种的 Resource 资源。不知道是不是还没有支持。
IDelayedExecutionPlugin IDelayedExecutionPlugin
接口提供了另外一个可选的 Query
方法:
1 2 3 4 public interface IDelayedExecutionPlugin { List<Result> Query (Query query, bool delayedExecution ) ; }
这个 Query
方法可以在一些耗时场景下使用(如网络或者本地 IO)。PowerToys Run 会在每次 Query 方法被调用前添加一个小的延迟,这样可以在等用户完成自己额外的输入后再触发调用 Query
方法(有点像是 debounce
去抖动方法)
IContextMenu
接口用于给 Query 查询结果添加一个关联菜单(context menu):
1 2 3 4 public interface IContextMenu { List<ContextMenuResult> LoadContextMenus (Result selectedResult ) ; }
ISettingProvider 如果插件功能已经比较复杂了,可以实现 ISettingProvider
为插件添加设置选项:
1 2 3 4 5 6 7 8 public interface ISettingProvider { Control CreateSettingPanel () ; void UpdateSettings (PowerLauncherPluginSettings settings ) ; IEnumerable<PluginAdditionalOption> AdditionalOptions { get ; } }
UpdateSettings
- 这个方法在用户更新 PowerToys Run 的设置时会被触发调用,因此我们一般实现这个方法来保存我们的自定义设置以及根据用户传来的新设置来更新我们插件的状态
AdditionalOptions
- PowerToys Run 的 GUI 界面显示我们的插件设置时会调用这个属性,因此我们通过这个属性来定义我们的设置如何在 PowerToys 的 GUI 图形界面渲染我们的设置项
CreateSettingPanel
- 大部分场景不需要创建设置面板,所以直接抛出 NotImplementedException
即可。
Classes 类 这里介绍一些比较重要的类对象,也主要是来自 Wox.Plugin
的:
PluginInitContext
Query
Result
ContextMenuResult
PluginInitContext 之前也提到,PluginInitContext
会作为 Init
方法的参数传入:
1 2 3 4 5 6 public class PluginInitContext { public PluginMetadata CurrentPluginMetadata { get ; internal set ; } public IPublicAPI API { get ; set ; } }
PluginMetadata
用于获取插件的元数据,比如 PluginDirectory
插件目录路径、ActionKeyword
触发关键字等
IPublicAPI
- 主要就是获取 GetCurrentTheme
并基于此做些可视化的配置工作(比如图标),除此之外还有 ShowMsg
、ShowNotification
以及 ChangeQuery
等方法。
Query 这里的 Query 不是方法,而是作为参数传入 Query 方法的 Query 类对象。
需要关注的 Query 属性有:
Search
即用户输入的文本,除外了触发关键词的部分
Terms
同样是用户输入,但是基于空格分割的文本集合(collections)
Result Query
方法的返回对象。
创建 Result
对象的例子如下面代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 new Result{ QueryTextDisplay = query.Search, IcoPath = IconPath, Title = "A title displayed in the top of the result" , SubTitle = "A subtitle displayed under the main title" , ToolTipData = new ToolTipData("A tooltip title" , "A tooltip text\nthat can have\nmultiple lines" ), Action = _ => { Log.Debug("The actual action of the result when pressing Enter." , GetType()); }, Score = 1 , ContextData = someObject, }
IContextMenu
接口定义的 LoadContextMenus
方法返回的对象。这些对象会被渲染一系列的小按钮,然后显示在每条查询结果的右侧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 new ContextMenuResult{ PluginName = Name, Title = "A title displayed as a tooltip" , FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets" , Glyph = "\xE8C8" , AcceleratorKey = Key.C, AcceleratorModifiers = ModifierKeys.Control, Action = _ => { Log.Debug("The actual action of the context menu result, when clicking the button or pressing the keyboard shortcut." , GetType()); }, }
这里可以找自己想要使用的 Glyph
图标:
Actions 操作 在 Query 返回的 Result 中可以通过定义 Action 函数来定义查询结果被选择后的操作(比如弹出提示框、打开浏览器等…)
而在 ContextMenuResult 的 Action 函数可以定义关联菜单项被点击后的操作:
1 2 3 4 5 Action = _ => { System.Windows.Clipboard.SetText("Some text to copy to the clipboard" ); return true ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 Action = _ => { var url = "https://conductofcode.io/" ; if (!Helper.OpenCommandInShell(DefaultBrowserInfo.Path, DefaultBrowserInfo.ArgumentsPattern, url)) { Log.Error("Open default browser failed." , GetType()); Context?.API.ShowMsg($"Plugin: {Name} " , "Open default browser failed." ); return false ; } return true ; }
Query 查询小插件代码示例 hlaueriksson 提供的 Demo 插件涵盖插件功能比较完整,这里提供一个更简单的示例,就是我自己写自己用的一个简单的卡路里热量值换算工具 KcalConverter ,用来把热量值千焦单位换算成千卡,平常记录自己饮食热量值的时候经常会用到。
这个插件相比 Demo 插件还要简单,主要就是实现了 Query 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public List<Result> Query (Query query ){ ArgumentNullException.ThrowIfNull(query); var isGlobalQuery = string .IsNullOrEmpty(query.ActionKeyword); if (string .IsNullOrEmpty(query.Search) || isGlobalQuery) { return []; } var success = decimal .TryParse(query.Search, out var number); if (!success) { return ErrorHandler.OnError(IconPath, query.RawQuery, "Invalid number format" ); } try { var result = number * 0.239006 m; var resultStr = result.ToString("F2" ); return [ new Result { Title = $"{number} kJ = {resultStr} kcal" , SubTitle = "将结果复制到剪贴板" , IcoPath = IconPath, Action = _ => { Clipboard.SetDataObject(resultStr); return true ; }, }, ]; } catch (Exception e) { return ErrorHandler.OnError(IconPath, query.RawQuery, errorMessage: e.Message, exception: e); } }
这段代码首先针对传入的 query 对象进行了 Search 与 ActionKeyword 属性的条件判断处理,只接收 Search 以及 ActionKeyword 有值的情况再推进后续逻辑。
然后是基于传入的 query.Search
文本尝试转换成 demical 数值类型(转换失败报错),进而将其值乘以 0.239006
换算成千卡单位的值,最后将结果封装成 Result 对象,通过 Action 匿名函数实现回车选中结果后将结果字符串复制到剪贴板,至此所有的逻辑结束。
(这里的 ErrorHandler.OnError
是自己实现的处理异常的工具方法,就是处理异常信息并输出日志,不是代码逻辑的主要内容)
Logging 日志 通过来自 Wox.Plugin.Logger
命名空间的静态类 Log
,我们可以输出插件日志。这个日志类的底层是 NLog
实现的。
1 2 3 4 5 Log.Debug("A debug message" , GetType()); Log.Info("An information message" , GetType()); Log.Warn("A warning message" , GetType()); Log.Error("An error message" , GetType()); Log.Exception("An exceptional message" , new Exception(), GetType());
日志会被写入到 .txt
后缀的文件中,并基于日期滚动,文件位置在:
%LocalAppData%\Microsoft\PowerToys\PowerToys Run\Logs\<Version>\
Dependencies 第三方依赖 如果你需要添加第三方依赖,首先看一下已经被 PowerToys 在使用的这些库:
这些对应版本的依赖理论上你可以直接在 .csproj
里引入而不需要进一步的操作。
一些可能有用的 Packages:
LazyCache
System.Text.Json
如果要用到上面没包含的第三方库,那么就需要在 plugin.json
开启 DynamicLoading
配置:
1 2 3 4 { "DynamicLoading" : true }
设置为 true
将会使 PowerToys Run 动态加载放在插件目录中的所有 .dll
文件。
Tests 测试 我们可以为自己的插件项目编写单元测试用例,官方插件使用 MSTest 测试框架以及 Moq
用于 Mocking。
项目名称: Community.PowerToys.Run.Plugin.<PluginName>.UnitTests
目标框架(Target Framework): net8.0-windows
对应单元测试项目 .csproj
文件差不多是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <Project Sdk="Microsoft.NET.Sdk" > <PropertyGroup> <TargetFramework>net8.0 -windows</TargetFramework> <Platforms>x64;ARM64</Platforms> <PlatformTarget>$(Platform)</PlatformTarget> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="MSTest.TestAdapter" Version="3.1.1" /> <PackageReference Include="MSTest.TestFramework" Version="3.1.1" /> <PackageReference Include="NLog" Version="5.0.4" /> <PackageReference Include="System.IO.Abstractions" Version="17.2.3" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\Community.PowerToys.Run.Plugin.Demo\Community.PowerToys.Run.Plugin.Demo.csproj" /> </ItemGroup> <ItemGroup Condition="'$(Platform)' == 'x64'" > <Reference Include="..\libs\x64\Wox.Plugin.dll" /> <Reference Include="..\libs\x64\PowerToys.Settings.UI.Lib.dll" /> </ItemGroup> <ItemGroup Condition="'$(Platform)' == 'ARM64'" > <Reference Include="..\libs\ARM64\Wox.Plugin.dll" /> <Reference Include="..\libs\ARM64\PowerToys.Settings.UI.Lib.dll" /> </ItemGroup> </Project>
然后基于 Demo 插件的例子,对应的单元测试代码差不多是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 using System;using System.Linq;using Microsoft.VisualStudio.TestTools.UnitTesting;namespace Community.PowerToys.Run.Plugin.Demo.UnitTests { [TestClass ] public class MainTests { private Main _subject = null !; [TestInitialize ] public void TestInitialize () { _subject = new Main(); } [TestMethod ] public void Query_should_calculate_the_number_of_words () { var results = _subject.Query(new ("" )); Assert.AreEqual("Words: 0" , results[0 ].Title); results = _subject.Query(new ("Hello World" )); Assert.AreEqual("Words: 2" , results[0 ].Title); } [TestMethod ] public void Query_should_calculate_the_number_of_characters () { var results = _subject.Query(new ("" )); Assert.AreEqual("Characters: 0" , results[1 ].Title); results = _subject.Query(new ("Hello World" )); Assert.AreEqual("Characters: 10" , results[1 ].Title); } [TestMethod ] public void LoadContextMenus_should_return_buttons_for_words_result () { var results = _subject.LoadContextMenus(new () { ContextData = (2 , TimeSpan.FromSeconds(3 )) }); Assert.AreEqual(2 , results.Count); Assert.AreEqual("Copy (Enter)" , results[0 ].Title); Assert.AreEqual("Copy time (Ctrl+Enter)" , results[1 ].Title); } [TestMethod ] public void LoadContextMenus_should_return_button_for_characters_result () { var results = _subject.LoadContextMenus(new () { ContextData = 10 }); Assert.AreEqual(1 , results.Count); Assert.AreEqual("Copy (Enter)" , results[0 ].Title); } [TestMethod ] public void AdditionalOptions_should_return_option_for_CountSpaces () { var options = _subject.AdditionalOptions; Assert.AreEqual(1 , options.Count()); Assert.AreEqual("CountSpaces" , options.ElementAt(0 ).Key); Assert.AreEqual(false , options.ElementAt(0 ).Value); } [TestMethod ] public void UpdateSettings_should_set_CountSpaces () { _subject.UpdateSettings(new () { AdditionalOptions = [new () { Key = "CountSpaces" , Value = true }] }); var results = _subject.Query(new ("Hello World" )); Assert.AreEqual("Characters: 11" , results[1 ].Title); } }
Distribution 发布 目前 PowerToys Run 的插件管理器并不支持直接下载新插件。社区的插件目前的发布方式主要还是在 Github Releases 页面中下载 zip 压缩包。
不过现在社区目前有个 8LWXpg 老师开发的 ptr 命令行 PowerToys Run 插件管理器,可以方便安装社区插件,感兴趣的老板可以去了解下。
整体的发布过程可以参考这个非官方的 Checklist:
而 Everything
插件作者 Lin Yu-Chieh (Victor) 更进一步, 其发布 内容物更加完整:
可执行文件 (EXE)
压缩文件 (ZIP)
WinGet
Chocolatey
Linting 代码检查工具 hlaueriksson 还专门创建了一个针对 PowerToys Run 社区插件的 Linter(代码检查工具):
运行 linter 之后这个 linter 会给你的插件报告任何可能的问题,每个问题会带上一个 Code 以及对应的描述。linter 的规则是正是基于之前提到的 Community plugin checklist 。
总结 对于程序员而言,经常会有各种自动化的小需求,而 PowerToys 又是一个对于使用 Windows 作为操作环境的程序员来说必不可少的软件了,所以自带的 PowerToys Run 能够自己开发一些插件还是能够解决不少自己的痛点,我自己就会进一步写一些私用的扩展(比如 OKX 交易所加密货币的多空比查询)。
希望这篇文章能够帮助到你,进一步解放自己的生产力(x
参考资料