ポモドーロタイマーです。
ユーザーを自分に限定すれば、固定時間で十分です。
パラメーターが少ないほどシンプルな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()
{
using var notifyIcon = new NotifyIcon
{
Visible = true ,
Icon = SystemIcons.Information,
BalloonTipTitle = Title,
BalloonTipText = Message,
BalloonTipIcon = ToolTipIcon.Info
};
await Task.Delay(TimeSpan.FromMinutes(15 ));
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]
private static async Task Main()
{
await Task.Delay(TimeSpan.FromMinutes(15 ));
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);
}
}
ロジックは同じくシンプルです。
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 }
[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の力をかりて、自分専用の小さいアプリケーションを粗製乱造していくのが、結構いい体験になりそうな予感がしました。
なおこのアプリケーションは次の講演をみて「自分の中のアプリケーションをつくる敷居をもっと下げた方がいい」と気がついてつくりはじめました。
speakerdeck.com
いい講演でした。