PowerToys Runのプラグインを自作する

Microsoftが出しているPowerToysというユーティリティ集が存在する。便利なユーティリティが集合したものだが、詳細についてはこちらを見て欲しい。

今回はこの中のPowerToys Runに注目する。PowerToys Runとは、ショートカットキーによって検索窓を表示してくれる機能で、macOSのSpotlightのWindows版と想像してくれるとわかりやすいと思う。

https://docs.microsoft.com/ja-jp/windows/images/pt-powerrun-demo.gif

この検索結果一覧は様々な種類があり、例えば ファイル/フォルダの検索や、そのまま標準ブラウザを開いてくれる機能、VSCodeワークスペースを検索する機能などがある。これらは、内部ではPluginとして個別に実装されている。

PowerToys/src/modules/launcher/Plugins at main · microsoft/PowerToys · GitHub

これが、以下のパスに設置されている。

C:\Program Files\PowerToys\modules\launcher\Plugins

これに対し、自分で好きな実装を施したプラグインを作るのが今回の目的。

その前に

現時点でサードパーティ製のプラグインはunsupportedなもので、扱いについては現在もこちらのissueで議論され続けているので、もしかしたら急にすべてが壊れるかもしれない。しかし、好意的な方向には見えるのでそんなに大きく事が変わることはないはず。

かんたんなプラグインを作る

まず、Pluginsの依存する各種ライブラリを用意するところから始める。以下の4つが対象だが、2022年5月29日現在はNuGet等には公開されていない*1ので PowerToys を自分でビルドするか、PowerToysインストール後に C:\Program Files\PowerToys\modules\launcher に設置されるそれぞれをコピーしておく必要がある。

  • PowerToys.Common.UI.dll
  • PowerToys.ManagedCommon.dll
  • Wox.Infrastructure.dll
  • Wox.Plugin.dll

次にプロジェクトを作成する。あまり.Netの文化に詳しくないが、net6.0-windowsnetcoreapp3.1 を対象にしたプロジェクトを用意する必要がある。どうするのが正攻法なのかはわからないが、クラスライブラリ をもとにプロジェクトを作成し、直接 .csproj を触っている。そして、依存についてこんな感じになるように追記していく。パスについては適時お好みの配置に換えてください。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0-windows</TargetFramework>
    <useWPF>true</useWPF>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="PowerToys.Common.UI">
      <HintPath>..\libs\PowerToys.Common.UI.dll</HintPath>
    </Reference>
    <Reference Include="PowerToys.ManagedCommon">
      <HintPath>..\libs\PowerToys.ManagedCommon.dll</HintPath>
    </Reference>
    <Reference Include="Wox.Infrastructure">
      <HintPath>..\libs\Wox.Infrastructure.dll</HintPath>
    </Reference>
    <Reference Include="Wox.Plugin">
      <HintPath>..\libs\Wox.Plugin.dll</HintPath>
    </Reference>
  </ItemGroup>

  <ItemGroup>
    <None Update="images\icon.png"> <!-- あとで作成する -->
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Update="plugin.json"> <!-- あとで作成する -->
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>

Main.cs を次のようにする。最小に近い状態。

using System.Windows;
using ManagedCommon;
using Wox.Plugin;

namespace PowerToysRunPluginSample
{
    public class Main : IPlugin
    {
        private string? IconPath { get; set; }

        private PluginInitContext? Context { get; set; }
        public string Name => "Cool Sample";

        public string Description => "This is cool sample plugin";

        public List<Result> Query(Query query)
        {
            return new List<Result>
            {
                new Result
                {
                    Title = "Copy COOL",
                    SubTitle = "Copy COOL",
                    IcoPath = IconPath,
                    Action = e =>
                    {
                        Clipboard.SetText("COOL");

                        return true;
                    },
                },
                new Result
                {
                    Title = $"Copy {query.Search}",
                    SubTitle = $"Copy {query.Search}",
                    IcoPath = IconPath,
                    Action = e =>
                    {
                        Clipboard.SetText(query.Search);

                        return true;
                    },
                },
            };
        }

        public void Init(PluginInitContext context)
        {
            Context = context;
            Context.API.ThemeChanged += OnThemeChanged;
            UpdateIconPath(Context.API.GetCurrentTheme());
        }

        private void UpdateIconPath(Theme theme)
        {
            IconPath = "images/icon.png";
        }

        private void OnThemeChanged(Theme currentTheme, Theme newTheme)
        {
            UpdateIconPath(newTheme);
        }
    }
}

images\icon.png を用意する。どういったものが要求されるかは真面目に見ていない。

plugin.json を用意する。次のような感じ。気になりどころを抑えておく。ID はGUID?ActionKeyword はクエリのprefixとして必要なものになるっぽい。sample であれば、sample hogefuga といった形でクエリを入れることになる。isGlobal は更に踏み込んで、このprefixすら必要ないものになるっぽい。その場合、スコアという概念が計算され、高いものであればあるほど高い順位に設定される様子。

{
  "ID": "EF1F634F20484459A3679B4FE7B07998",
  "Disabled": false,
  "ActionKeyword": "sample",
  "Name": "PowerToysRunPluginSample",
  "Author": "naari3",
  "Version": "1.0.0",
  "Language": "csharp",
  "Website": "https://github.com/naari3/PowerToysRunPluginSample",
  "ExecuteFileName": "PowerToysRunPluginSample.dll",
  "IsGlobal": false,
  "IcoPathDark": "images\\icon.png",
  "IcoPathLight": "images\\icon.png"
}

この状態でビルドする。bin/Debug あたりに色々吐き出されるが、このうち以下の4つを C:\Program Files\PowerToys\modules\launcher\Plugins\PowerToysRunPluginSample フォルダにコピーする。

  • `imagesP
  • plugin.json
  • PowerToysRunPluginSample.deps.json
  • PowerToysRunPluginSample.dll

この状態で PowerToys を再起動し、ショートカットキーから PowerToys Run を起動すると以下のようにプラグインの挙動を実現できる。

もし何らかうまく行っていない場合、ログを見ると良い。以下の場所にバージョンごとのログがあり、何かに失敗した場合はおそらくここにその情報が追記されている。

%LOCALAPPDATA%\Microsoft\PowerToys\PowerToys Run\Logs

ここまでの状態をGitHubリポジトリとして上げているので、そちらで見たい人はどうぞ

github.com

NuGetから依存関係を追加する

NuGetから依存関係を追加し、何らかの挙動を追加したくなるのだが、これが結構大変だった。結論から書くと、結局依存関係を解決してくれなかったためILRepackなどでひとつのdllにマージする必要があった。C#のdllの扱いを全く知らないので憶測でしかないし、おま環かもしれないが、おそらく公式プラグインに必要な依存はなにか別の方法で解決されているのだと思う。それっぽいものは C:\Program Files\PowerToys\modules\launcher に置いているようだったので、このあたりには何か関係していそうな気はする。

ので、依存関係をすべてマージする方法を書く。ILRepackというのは過去存在したILMergeというツールのフォーク版らしい*2

まず PropertyGroup<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> を追加する。これにより、依存したライブラリもOutDirに出力されるようになる。

  <PropertyGroup>
    <!-- snip -->
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
    <!-- snip -->
  </PropertyGroup>

その後、ILRepack.Lib.MSBuild.Task をNuGet経由で追加し、ILRepack.targets に以下のように記述する。これにより、dllが結合されるようになる。この際、依存したライブラリが依存しているライブラリも追加する必要があるので注意が必要。

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <Target Name="ILRepacker" AfterTargets="Build">

    <ItemGroup>
        <InputAssemblies Include="$(OutputPath)\依存したライブラリ.dll" />
        <InputAssemblies Include="$(OutputPath)\依存したライブラリ2.dll" />
        <InputAssemblies Include="$(OutputPath)\$(AssemblyName).dll" />
    </ItemGroup>

    <ILRepack
        Parallel="true"
        Internalize="true"
        InternalizeExclude="@(DoNotInternalizeAssemblies)"
        InputAssemblies="@(InputAssemblies)"
        TargetKind="Dll"
        OutputFile="$(OutputPath)\$(AssemblyName).dll"
    />
    </Target>
</Project>

この状態でビルドし、dllを追加すると動くようになる。お試しあれ。

参考実装たち

*1:issueを見ると一応考えがなくはないっぽいが、まだ先の話になりそうな雰囲気。

*2:最初ILMergeを使用していたが、様々な問題にさしあたったので使うのをやめた