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

実はこのウィジェット、 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つで、まずは名前空間を追加設定します。 uap3 と com の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:Class の Id には先ほど 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:AppExtension の Id および DisplayName に自由な名前 (これはユーザーに表示されます) を、 CreateInstance の ClassId には guidgen で生成した GUID を設定します。

次に、ウィジェットの定義ですが、 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 にはプレビュー画面で表示されるスクリーンショットを設定します。

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