@ledsun blog

無味の味は佳境に入らざればすなわち知れず

15分タイマー

ポモドーロタイマーです。 ユーザーを自分に限定すれば、固定時間で十分です。 パラメーターが少ないほどシンプルな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の力をかりて、自分専用の小さいアプリケーションを粗製乱造していくのが、結構いい体験になりそうな予感がしました。

なおこのアプリケーションは次の講演をみて「自分の中のアプリケーションをつくる敷居をもっと下げた方がいい」と気がついてつくりはじめました。

speakerdeck.com

いい講演でした。