ポモドーロタイマーです。 ユーザーを自分に限定すれば、固定時間で十分です。 パラメーターが少ないほどシンプルなUIになるはずです。
ChatGPTと相談しながら作りました。 最終的にWindows.UI.Notifications.ToastNotificationManagerをつかった通知アプリケーションになりました。
通知のスクリーンショット
最初にどんな感じになったかスクリーンショットを貼っておきます。

最初のバージョン
最初はSystem.Windows.Forms.NotifyIconを使いました。 10分くらいで作れました。
internal static class Program { // 固定メッセージをここで設定 private const string Title = "お知らせ"; private const string Message = "休憩の時間です。ストレッチしましょう!"; [STAThread] private static async Task Main() { // コンソールウィンドウは表示されますが、画面(UI)は作りません using var notifyIcon = new NotifyIcon { Visible = true, Icon = SystemIcons.Information, // 既定の情報アイコン(ico不要) BalloonTipTitle = Title, BalloonTipText = Message, BalloonTipIcon = ToolTipIcon.Info }; // 15分待機 await Task.Delay(TimeSpan.FromMinutes(15)); // 5秒間のバルーン表示 notifyIcon.ShowBalloonTip(5000); // 表示のため少し待ってから終了(任意) await Task.Delay(6000); } }
ロジックもシンプルで分かりやすいです。
dotnet publishコマンドで生成されるファイルが230個位できます。
自分用アプリケーションなのでインストーラーは作りません。数百ファイルを手動で管理するのは面倒です。
ワンバイナリ化
dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true --self-contained trueコマンドを使うと一つのバイナリにまとめられます。
dotnetのランタイムを含むため100MBを越えてきます。
いいんだけど、もう少し小さくしたいです。
.NET 7 からNative AOT (Ahead-of-Time) コンパイルという技術があります。 これを使えば数MBまで小さく出来ます。 Windows.Formsには使えません。 使おうとすると次のエラーが出ます。
DesktopNotifier 1 件のエラーで失敗しました (0.0 秒)
C:\Program Files\dotnet\sdk\9.0.304\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInference.targets(258,5): error NETSDK1175: Windows フォームに関して、トリミングの有効化はサポートおよび推奨されていません。詳細については、https://aka.ms/dotnet-illink/windows-forms を参照してください。
1.9 秒後に 1 件のエラーで失敗しました をビルド
Windows.UI.Notifications.ToastNotificationManager
Windows.Forms依存をなくすために、 System.Windows.Forms.NotifyIconに代わり Windows.UI.Notifications.ToastNotificationManager を使います。
using System.Security; using Windows.Data.Xml.Dom; using Windows.UI.Notifications; internal static class Program { private const string AppUserModelID = "YourCompany.DesktopNotifier"; private const string Title = "お知らせ"; private const string Message = "休憩の時間です。ストレッチしましょう!"; [STAThread] // WinRT/シェル周りはSTA推奨 private static async Task Main() { // 15分待機(デバッグ時は秒に変えてOK) await Task.Delay(TimeSpan.FromMinutes(15)); // トースト(最小XML) string xml = $@" <toast> <visual> <binding template='ToastGeneric'> <text>{SecurityElement.Escape(Title)}</text> <text>{SecurityElement.Escape(Message)}</text> </binding> </visual> </toast>"; var doc = new XmlDocument(); doc.LoadXml(xml); var notifier = ToastNotificationManager.CreateToastNotifier(AppUserModelID); var toast = new ToastNotification(doc); notifier.Show(toast); // 送信直後に終了しても通知は出ますが、念のため少しだけ猶予 // await Task.Delay(1500); } }
ロジックは同じくシンプルです。
Windows.UI.Notifications.ToastNotificationManager で通知を表示するには制約があります。
ToastNotificationManager クラス (Windows.UI.Notifications) - Windows UWP applications | Microsoft Learn
デスクトップ アプリでトーストを表示するには、アプリのスタート画面にショートカットが必要です。 ショートカットには AppUserModelID が必要です。
ショートカットを作成する
ここからがややこしいです。 ChatGPTにPowerShellを書いてもらいました。
$ExePath = "C:\Users\led_l\DesktopNotifier\bin\Release\net9.0-windows10.0.19041.0\win-x64\publish\DesktopNotifier.exe" # 実行ファイルの実パス $Lnk = "$([Environment]::GetFolderPath('StartMenu'))\Programs\DesktopNotifier.lnk" $WshShell = New-Object -ComObject WScript.Shell $link = $WshShell.CreateShortcut($Lnk) $link.TargetPath = $ExePath $link.WorkingDirectory = Split-Path $ExePath $link.Description = "DesktopNotifier" $link.Save() Add-Type -Language CSharp -TypeDefinition @" using System; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; public static class LnkAumidHelper { [ComImport, Guid("00021401-0000-0000-C000-000000000046")] private class CShellLink { } [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] private interface IPropertyStore { int GetCount(out uint cProps); int GetAt(uint iProp, out PROPERTYKEY pkey); int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); int SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); int Commit(); } [StructLayout(LayoutKind.Sequential, Pack=4)] private struct PROPERTYKEY { public Guid fmtid; public uint pid; } private static readonly PROPERTYKEY PKEY_AppUserModel_ID = new PROPERTYKEY { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 }; [StructLayout(LayoutKind.Sequential)] private struct PROPVARIANT { public ushort vt, w1, w2, w3; public IntPtr p; public int i1, i2; } const ushort VT_LPWSTR = 31; [DllImport("ole32.dll")] private static extern int PropVariantClear(ref PROPVARIANT pvar); [ComImport, Guid("000214F9-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellLinkW { int GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, IntPtr pfd, uint fFlags); int GetIDList(out IntPtr ppidl); int SetIDList(IntPtr pidl); int GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch); int SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); int GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch); int SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); int GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch); int SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); int GetHotkey(out short wHotkey); int SetHotkey(short wHotkey); int GetShowCmd(out int iShowCmd); int SetShowCmd(int iShowCmd); int GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cch, out int iIcon); int SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); int SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); int Resolve(IntPtr hWnd, uint fFlags); int SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); } [ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IPersistFile { int GetClassID(out Guid pClassID); int IsDirty(); int Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, int dwMode); int Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, bool fRemember); int SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName); int GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName); } public static void SetAumid(string lnkPath, string appId) { // ReadWrite で開く(= 2) const int STGM_READWRITE = 0x00000002; var link = (IShellLinkW)new CShellLink(); var persist = (IPersistFile)link; int hr = persist.Load(lnkPath, STGM_READWRITE); if (hr != 0) Marshal.ThrowExceptionForHR(hr); var store = (IPropertyStore)link; var key = PKEY_AppUserModel_ID; PROPVARIANT pv = new PROPVARIANT { vt = VT_LPWSTR, p = Marshal.StringToCoTaskMemUni(appId) }; try { Marshal.ThrowExceptionForHR(store.SetValue(ref key, ref pv)); Marshal.ThrowExceptionForHR(store.Commit()); // 念のため明示保存 Marshal.ThrowExceptionForHR(persist.Save(lnkPath, true)); persist.SaveCompleted(lnkPath); } finally { PropVariantClear(ref pv); Marshal.ReleaseComObject(store); Marshal.ReleaseComObject(persist); Marshal.ReleaseComObject(link); } } } "@ # 事前に読み取り専用属性が付いていたら外す if (Test-Path $Lnk) { attrib -R $Lnk } # AppUserModelID を設定 [LnKAumidHelper]::SetAumid($Lnk, "YourCompany.DesktopNotifier") "OK: AppUserModelID set -> $Lnk"
アプリケーション本体の2倍を超えるインストールスクリプトです。 AppUserModelID はCOMを使わないと設定できないようです。
プロジェクトファイル
DesktopNotifier.csproj
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net9.0-windows10.0.19041.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <PublishAot>true</PublishAot> </PropertyGroup> </Project>
その他の注意点
Native AOT (Ahead-of-Time) コンパイルにはC++の開発環境が必要です。 Visual Studioで言うと「C++ によるデスクトップ開発」 ワークロードをインストールしてある必要があります。
まとめ
ショートカットキーが必要になったので、結果的にキーボードから起動できるようになりました。 ちょっとお得な気分です。
AIを使うと要件を絞った小さいアプリケーションを短時間で作れます。 AIの力をかりて、自分専用の小さいアプリケーションを粗製乱造していくのが、結構いい体験になりそうな予感がしました。
なおこのアプリケーションは次の講演をみて「自分の中のアプリケーションをつくる敷居をもっと下げた方がいい」と気がついてつくりはじめました。
いい講演でした。