なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

Windows のウィジェットを作ろう!

Windows でウィジェット使っていますか?わたしはあまり使っていませんでしたが、これを期に使い始めました。 Windows におけるウィジェットとは、 Windows + W キーで横から出てくるボードに配置できるアプリケーションの事です。

ウィジェットの様子、自作が4つ並んでいる

www.microsoft.com

実はこのウィジェット、 Windows App SDK 1.2 のリリース (だいたい 2022年) からサードパーティにも開放されており、誰でも作ることが出来るのです。 ということで、今回はそんなウィジェットを作っていく記事です。

ウィジェットアプリがサポートしている形式は大きく分けて3種類あり、 C++/WinRT、 C#、 PWA の3種類です。今回は C# で開発していきます。 事前に Visual Studio Installer から、 Windows SDK の最新版を導入しておいてください。

基本的には公式ドキュメントの Implement a widget provider in a C# Windows App - Windows apps | Microsoft Learn に従って実装すればよく、 UI 実装とデバッグがやたら難しいことを除けば、通常のコンソールアプリケーションと変わりません。 迷ったら先ほどのドキュメントか、もしくは公式サンプルも公開されているので、それを参考にやっていきましょう。

まずは Visual Studio から WinUI Blank App アプリケーションを作成します。

作成したら、 XAML 類はすべて不要なので削除してください。 次に COM Interop 部分 (WidgetProviderFactory) を作成します。ここは公式ドキュメント / GitHub の公式サンプルをそのままコピペで良いでしょう。 コピペしてきたら、 Program.cs を作成します。 factory 変数に代入している Guid.Parse の引数には、 guidgen で生成した GUID を設定してください。

using System;
using System.Runtime.InteropServices;

using AkashaWin;
using AkashaWin.COM;

[DllImport("kernel32.dll")]
static extern IntPtr GetConsoleWindow();

[DllImport("ole32.dll")]
static extern int CoRegisterClassObject([MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, [MarshalAs(UnmanagedType.IUnknown)] object pUnk, uint dwClsContext, uint flags, out uint lpdwRegister);

[DllImport("ole32.dll")]
static extern int CoRevokeClassObject(uint dwRegister);

var factory = Guid.Parse("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx");
CoRegisterClassObject(factory, new WidgetProviderFactory<AkashaWidgetProvider>(), 0x4, 0x1, out var register);

if (GetConsoleWindow() != IntPtr.Zero)
{
    // なにもしない
}
else
{
    using var evt = AkashaWidgetProvider.EmptyWidgetsEvent;
    evt.WaitOne();

    CoRevokeClassObject(register);
}

次に、 IWidgetProvider を実装したクラス AkashaWidgetProvider (Akasha はわたしが作成したウィジェットの名前です、自身のプロダクトの名前に置き換えてください) を作成します。名前はそれらしいモノであればなんでも良いです。テンプレートとして、次のような形になるでしょう。

using System;
using System.Collections.Generic;
using System.Threading;

using AkashaWin.Models;

using Microsoft.Windows.Widgets.Providers;

namespace AkashaWin
{
    public class AkashaWidgetProvider : IWidgetProvider
    {
        public static readonly Dictionary<string, IAkashaWidget> RunningWidgets = new();
        public static ManualResetEvent EmptyWidgetsEvent => new(false);

        public AkashaWidgetProvider()
        {
            // 前回までにウィジェットボードに表示していたウィジェットの情報を取得
            var widgets = WidgetManager.GetDefault().GetWidgetInfos();
            foreach (var widget in widgets)
            {
                var context = widget.WidgetContext;
                var id = context.Id;
                var name = context.DefinitionId;
                var state = widget.CustomState;

                if (RunningWidgets.ContainsKey(id))
                    continue;

                IAkashaWidget? w = null;

                switch (name)
                {
                    // ウィジェット名を元に、インスタンスを復元
                    case "AkashaWin_Feed":
                        w = new AkashaFeedWidget { Id = id, Name = name };
                        break;
                }

                if (w != null)
                {
                    RunningWidgets.Add(id, w);
                    UpdateWidget(w);
                }
            }
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.createwidget?view=windows-app-sdk-1.6
        public void CreateWidget(WidgetContext widgetContext)
        {
            var id = widgetContext.Id;
            var name = widgetContext.DefinitionId;
            AkashaWidgetBase? widget = null;

            switch (name)
            {
                // ウィジェット名を元に、インスタンスを作成
                case "AkashaWin_Feed":
                    widget = new AkashaFeedWidget { Id = id, Name = name };
                    break;
            }

            if (widget != null)
            {
                RunningWidgets.Add(id, widget);
                UpdateWidget(widget);
            }
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.deletewidget?view=windows-app-sdk-1.6
        public void DeleteWidget(string widgetId, string customState)
        {
            if (RunningWidgets.TryGetValue(widgetId, out var widget))
            {
                RunningWidgets.Remove(widgetId);

                if (RunningWidgets.Count == 0)
                    EmptyWidgetsEvent.Set();
            }
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.onactioninvoked?view=windows-app-sdk-1.6
        public void OnActionInvoked(WidgetActionInvokedArgs actionInvokedArgs)
        {
            var verb = actionInvokedArgs.Verb;

            switch (verb)
            {
                // アクションに応じてコードを実行する、今回は解説しない
            }
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.onwidgetcontextchanged?view=windows-app-sdk-1.6
        public void OnWidgetContextChanged(WidgetContextChangedArgs contextChangedArgs)
        {
            var context = contextChangedArgs.WidgetContext;
            var id = context.Id;
            var size = context.Size;

            if (RunningWidgets.TryGetValue(id, out var widget))
                UpdateWidget(widget);
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.activate?view=windows-app-sdk-1.6
        public void Activate(WidgetContext widgetContext)
        {
            var id = widgetContext.Id;

            if (RunningWidgets.TryGetValue(id, out var widget))
            {
                widget.IsActive = true;

                UpdateWidget(widget);
            }
        }

        // https://learn.microsoft.com/ja-jp/windows/windows-app-sdk/api/winrt/microsoft.windows.widgets.providers.iwidgetprovider.deactivate?view=windows-app-sdk-1.6
        public void Deactivate(string widgetId)
        {
            if (RunningWidgets.TryGetValue(widgetId, out var widget))
                widget.IsActive = false;
        }

        private void UpdateWidget(IAkashaWidget widget)
        {
            // ウィジェットの UI の更新をリクエスト
            var options = new WidgetUpdateRequestOptions(widget.Id) { Template = widget.GetTemplate(), Data = "{}" };
            WidgetManager.GetDefault().UpdateWidget(options);
        }
    }
}

コンストラクターでは、アプリケーションの起動時、復元時などに前回までにウィジェットボードに保存されたインスタンスを復元するためのコードが記述されています。 ウィジェットをメニューから作成する際は CreateWidget メソッドが、削除時には DeleteWidget メソッドが呼び出されます。

ここででてくる IAkashaWidget は、ウィジェットに必要なプロパティ、メソッドを定義したインターフェースで、次のような定義をしています:

namespace AkashaWin.Models
{
    public interface IAkashaWidget
    {
        string Id { get; }

        string Name { get; }

        bool IsActive { get; set; }

        string GetTemplate();
    }
}

個々のウィジェットを区別するための Id 、どのウィジェットがインスタンス化されているかの Name 、あとは UI テンプレートを返すための GetTemplate() 関数です。 IsActive はあっても無くてもかまいません。 実際に IAkashaWidget を実装したモデルは次のような形になるでしょう:

namespace AkashaWin.Models
{
    public class AkashaFeedWidget : IAkashaWidget
    {
        public string Id { get; init; }

        public string Name { get; init; }

        public bool IsActive { get; set; }

        public override string GetTemplate()
        {
            return $$"""JSON テンプレート""";
        }
    }
}

次に、UI を示す JSON テンプレートを定義します。 JSON テンプレートは Adaptive Cards に従ったものを返す必要があり、基本的には Adaptive Cards Designer を使ってデザインし、それをテンプレート化したもの、もしくはそのままのものを返します。 例として、ローディング画面であれば次のような JSON を返すと良いです:

{
  "type": "AdaptiveCard",
  "$schema": "https://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.6",
  "speak": "loading...",
  "body": [
    {
      "type": "ProgressRing",
      "size": "Large",
      "label": "Loading...",
      "horizontalAlignment": "Center",
      "height": "stretch"
    }
  ]
}

次に、マニフェストにウィジェット情報を記述します。どんな外観か、どんな名前か、を自動生成された アプリ パッケージ マニフェスト (appxmanifest) に定義します。 必要なのは3つで、まずは名前空間を追加設定します。 uap3com の2つの XML 名前空間を新たに定義してください。

<Package
  ...
  xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
  xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"

次に COM へとクラスを登録するための情報を Application 配下、具体的には uap:VisualElement の後ろに配置します。基本的には次のような形になるでしょう:

<Package>
  <Applications>
    <Application>
        ...
      </uap:VisualElements>

      <Extensions>
        <com:Extension Category="windows.comServer">
          <com:ComServer>
            <com:ExeServer Executable="AkashaWin\AkashaWin.exe" DisplayName="AkashaWidgetProvider">
              <com:Class Id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" DisplayName="AkashaWidgetProvider" />
            </com:ExeServer>
          </com:ComServer>
        </com:Extension>

        ...

com:ClassId には先ほど guidgen で生成した GUID を、 DisplayName には好きな名前 (ユーザーに表示されることはありません) を、 Executable には実行ファイルへのパス、通常は ${ProjectName}/${ProjectName}.exe を指定します。

最後に、ウィジェット情報を定義します。先ほど追加した com:Extension の後ろに、次のように追加します:

         ...
        </com:Extension>

        <uap3:Extension Category="windows.appExtension">
          <uap3:AppExtension Name="com.microsoft.windows.widgets" DisplayName="AkashaWin" Id="AkashaWinApp" PublicFolder="Public">
            <uap3:Properties>
              <WidgetProvider>
                <ProviderIcons>
                  <Icon Path="Images\StoreLogo.png" />
                </ProviderIcons>
                <Activation>
                  <!-- Apps exports COM interface which implements IWidgetProvider -->
                  <CreateInstance ClassId="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
                </Activation>

                <TrustedPackageFamilyNames>
                  <TrustedPackageFamilyName>Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe</TrustedPackageFamilyName>
                </TrustedPackageFamilyNames>

                <Definitions>
                </Definitions>
              </WidgetProvider>
            </uap3:Properties>
          </uap3:AppExtension>
        </uap3:Extension>
        ...

uap3:AppExtensionId および DisplayName に自由な名前 (これはユーザーに表示されます) を、 CreateInstanceClassId には guidgen で生成した GUID を設定します。

AppExtension の名前

次に、ウィジェットの定義ですが、 Definitions 内部に次のように定義します:

                ...
                <Definitions>
                  <Definition Id="AkashaWin_Feed"
                              DisplayName="Akasha - フィード"
                              Description="Catalyst に投稿された最新の投稿を表示します。"
                              AllowMultiple="false">
                    <Capabilities>
                      <Capability>
                        <Size Name="medium" />
                      </Capability>
                    </Capabilities>
                    <ThemeResources>
                      <Icons>
                        <Icon Path="ProviderAssets\Icon.png" />
                      </Icons>
                      <Screenshots>
                        <Screenshot Path="ProviderAssets\Feed_Screenshot.png" DisplayAltText="For accessibility" />
                      </Screenshots>
                      <!-- DarkMode and LightMode are optional -->
                      <DarkMode />
                      <LightMode />
                    </ThemeResources>
                  </Definition>

Id には一意な名前 (ここに設定した名前が WidgetContext.DefinitionId に渡されます) を、DisplayName には自由な名前 (ユーザーに表示されます) を、 Description には説明 (おそらく表示されていない) を設定します。 複数のインスタンスを許容する場合は AllowMultiple 属性を削除してください。デフォルト true が設定されます。 Capability には large / medium / small のいずれか1つ以上のサイズを定義します。 ここで設定したサイズのみ、ウィジェットで設定できます。 Icon にはアイコンを、 Screenshot にはプレビュー画面で表示されるスクリーンショットを設定します。

DisplayName と Screenshot が表示されている様子

ここまで来たら、ウィジェットの完成です。ひとまず表示出来るものができます。

デバッグのポイント

ウィジェットボードそのものが割と不安定で、 Visual Studio で起動したとしてもウィジェットが認識されず、表示されないケースがあります。 その場合は、タスクマネージャーから、 WidgetPlatformRuntime の子である WidgetService.exe を kill することで、再起動を促し、認識させることが出来ます。