2014/02/07

非.NET4.5でもトースト通知

Windows 8以降のトースト通知を.NET Framework 4.5以降でないアプリから使う方法について。特別新しい話はないです。

1. アウトライン


OSやアプリからユーザーに何か通知するとき、従来はよくバルーンメッセージが使われていましたが、Windows 8以降はトースト通知が使われるようになりました。このトースト通知はデスクトップアプリからでも出せますが、Windowsストアアプリ用のAPIであるWindows Runtimeを使うので、必然的に.NET Framework 4.5以降のアプリである必要があります。

ここで.NET Framework 4.5以降でアプリを作ってしまえば話は終わりですが、そうも行かないときにどうしようかな、ということで方法を考えたので、それを説明してみます。

先に、作成したテストアプリを示しておきます。このアプリ自体は.NET Framework 4.0で作成しています。

この"Headline"と"Body"の部分に入力して、"Background color"を適当に調整して、"Show a toast"を押すと、以下のようにトースト通知が出ます。

ここでトーストをクリックすると、もしこのアプリが最小化されていたり他のウィンドウの下にあっても最前面に表示し直されます。

これは割とチープな仕掛けで、
  1. トースト通知を指示するアプリ(この表示されているアプリ。.NET 4.0のWPFアプリ)
  2. トースト通知を実際に出すアプリ(.NET 4.5のコンソールアプリ)
を用意しておき、このWPFアプリ(親アプリ)からコンソールアプリ(子アプリ)を外部実行し、標準入力でトースト通知の内容を送り、それに従ってコンソールアプリはトースト通知を出し、トーストがクリックされたという反応が返ってくると、それを標準出力でWPFアプリに返し、それを受けてWPFアプリが自らを最前面に出す、という流れになっています。なぜライブラリでやらないかというと、.NETのバージョンが新しいものを古いものの方から参照して使えない(逆は可能)という問題があるからです。

こういうトースト用アプリを介するという発想は別に珍しくなくて、Lenovoのユーティティでも使っています。

テストアプリは共にC#でVisual Studio 2013により作成しています。
実行ファイル
ソースコード

2. 具体的方法


トースト通知についてはMSDNにしっかりした説明があるので、ポイントだけ触れます。

2.1 ガイドライン/h4>
初めにガイドラインを確認しておくと、出たばかりのWindows 8.1 UXガイドラインの日本語訳からトースト通知のガイドライン(内容的にはトースト通知のガイドライン (Windowsストアアプリ)と同じ)を見ると、
ユーザーがトーストをタップしたときに、アプリの適切な移動先に移動します。通知は厳密な情報更新ではなく、コンテキストの切り替えをユーザーに促すものと考えてください。

とあって、トーストは出しっ放しではなく、クリックされたとき(Activatedイベント)適切な情報に誘導すべきということが分かります。また、
バルーン通知のシナリオをトーストに自動的に移行しないでください。ユーザーが全画面表示のエクスペリエンス (デスクトップスタイルアプリのみ) に没入していないときには、バルーン通知の方が適している場合もあることを考慮します。

ともあってギクッとします。デスクトップにしか関係ないものはわざわざトーストにしなくていい場合があると。

その他さほど細々した指示はなくて、大意をまとめれば「トーストを濫発されると全体の迷惑だから、つまらない通知に使うな、なるべく絞れ」というプラットフォーム側としては当然の要請だと思います。

2.2 トーストテンプレート


トースト通知の出し方を簡単にいえば、専用書式のXMLに設定を乗せてAPIに渡す、それだけです。そのXMLのベースとなるテンプレートは8種類用意されていて、新規作成はできません。というより、このXMLからトーストを表示するためのCSSというかスタイル設定がこれら既定のテンプレートに対応したものだけで、自由に追加できないといった方が正しいかも。

このテンプレートはMSDNのトーストテンプレートカタログにあるとおりですが、実際のところ、たいして選択肢はありません。
  • 画像を入れるか否か。
  • 3行の文字スペースを件名(Headline)と本文(Body)にどう割り当てるか。
だけです(スタートメニューに置いたショートカットのアイコンは常に入る)。なお、色はショートカットのタイルのものが利用されるので、トーストの設定にはありません。

具体的には、
  1. ToastText01(文字のみ、本文のみ)
  2. ToastText02(文字のみ、件名1行、本文2行)
  3. ToastText03(文字のみ、件名2行、本文1行)
  4. ToastText04(文字のみ、件名1行、本文1行×2)
  5. ToastImageAndText01(画像入り、本文のみ)
  6. ToastImageAndText02(画像入り、件名1行、本文2行)
  7. ToastImageAndText03(画像入り、件名2行、本文1行)
  8. ToastImageAndText04(画像入り、件名1行、本文1行×2)
の通りですが、汎用性が高いのはToastText02だと思うので、以後これで進めます(テストアプリもこれを使用)。

まずToastNotificationManagerからテンプレートを取得します。型はWindows RuntimeのXMLであるWindows.Data.Xml.Dom.XmlDocumentです。
var document = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastText02);
このXMLの内容はカタログにあるとおり以下のようなものです(改行を入れて整形したもの)。
<toast>
    <visual>
        <binding template="ToastText02">
            <text id="1"></text>
            <text id="2"></text>
        </binding>
    </visual>
</toast>
後はこれをこねこねすればいいわけです。書式はMSDNのToast schemaに説明があります。といってもデスクトップアプリから出すトーストの場合、使えるのは一部だけで、toastaudioぐらいです。

以下では最初に件名(headline)と本文(body)を入れた後、トーストの表示時間を長くし、それに合わせてオーディオはループするよう設定しています。
// Fill in text elements.
var textElements = document.GetElementsByTagName("text");
if (textElements.Length == 2)
{
    textElements[0].AppendChild(document.CreateTextNode(headline));
    textElements[1].AppendChild(document.CreateTextNode(body));
}

// Set duration attribute.
document.DocumentElement.SetAttribute("duration", "long");

// Add audio element.
var audioElement = document.CreateElement("audio");
audioElement.SetAttribute("src", "ms-winsoundevent:Notification.Looping.Alarm");
audioElement.SetAttribute("loop", "true");
document.DocumentElement.AppendChild(audioElement);
これでXMLは例えば以下のようになります(整形したもの。先にテストアプリで出したトーストと同じ内容)。この内容が毎回同じなら、これをテキストリソースとして持っておいてXMLを生成した方が早いですね。
<toast duration="long">
    <visual>
        <binding template="ToastText02">
            <text id="1">トースト通知のテスト</text>
            <text id="2">トーストの本気を見るのです!</text>
       </binding>
    </visual>
    <audio src="ms-winsoundevent:Notification.Looping.Alarm" loop="true"/>
</toast>
このXMLでToastNotificationオブジェクトを生成し、AppUserModelIDと合わせてToastNotificationManagerに託せば終わりです。
// Create a toast.
var toast = new ToastNotification(document);

// Show a toast.
ToastNotificationManager.CreateToastNotifier(AppId).Show(toast);

2.3 トーストイベント


トーストを出した結果はイベントで取得できます。というより、トーストがクリックされたかどうか知る必要があるので、トーストを出す前にイベントハンドラーを登録しておきます。イベントは3種類で、うち1つは理由が3つに分かれるので、計5種類の結果があります。
  • ToastNotification.Activated(トーストを出すのに成功し、ユーザーがクリックした)
  • ToastNotification.Dismissed
    • ToastDismissalReason.ApplicationHidden(アプリでトーストが抑止されていた)
    • ToastDismissalReason.UserCanceled(トーストを出すのに成功したが、ユーザーがクローズボタンで閉じた=積極的に無視した)
    • ToastDismissalReason.TimedOut(トーストを出すのに成功したが、クリックされないまま時間切れになった=ユーザーが気づかなかったか、消極的に無視した)
  • ToastNotification.Failed(トーストを出すのに失敗した)
このうちアプリがアクションを起こす必要があるのはActivatedイベントの場合だけなので、それだけで十分かもしれません。ちなみに、このイベントはサブスレッドで起きるので、UIを操作するにはUIスレッドに戻す必要がありますが、コンソールアプリでは関係ないです。

なお、コンソールアプリでイベントを受け取るにはイベントが返ってくるまで(表示時間を長くした場合、時間切れまで25秒)アプリが終了しないようにする必要があるので、テストアプリではトーストを出した後、System.Threading.Thread.Sleepメソッドを500ミリ秒ずつ60回ループさせて計30秒ほどUIスレッドをブロックするようにし、イベントが返ってきたらループを出るようにしています。

2.4 アプリのショートカット


上でも出てきましたが、トースト通知を出すにはそのアプリのAppUserModelIDの入ったショートカットがスタートメニューにある必要があります。このAppUserModelID入りのショートカットを作る機能は.NET Frameworkでは提供されていません。IShellLinkにもありません。Windows Script HostのWshShortcutにもありません。

となるとWin32APIとCOMでやるしかないわけですが、Windows API Code Packのライブラリにこれが使えるものあります。どうせならとこれとIShellLinkの機能をまとめたラッパークラスを作るというのを以前やりました。

このShellLinkクラスの機能を実用レベルに上げたものをテストアプリでは使っています。したがって一般的な説明にはならないのですが、こんな感じでショートカットを作成しています。
using (var shortcut = new ShellLink()
{
    TargetPath = Assembly.GetExecutingAssembly().Location;
    Arguments = StartmenuArgument,
    AppUserModelID = AppId,
    IconPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), ExecutableFile),
    IconIndex = 0,
    WindowStyle = ShellLink.SW.SW_SHOWMINNOACTIVE,
})
{
    shortcut.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ShortcutFile));
}
各プロパティの設定内容は、
  • TargetPathはショートカットが示す実行ファイルのパスで、このコンソールアプリ(子アプリ)自身のパスを入れています。
  • Argumentsはショートカットの引数で、ショートカットから起動されたことを判別するための定数を入れています(意図は後述)。
  • AppUserModelIDはその名のとおり。
  • IconPathはショートカットのアイコンに使うアイコンで、ここにはWPFアプリ(親アプリ)のアイコンを利用するため、同じフォルダーにあるという前提で、そのパスを入れています(ExecutableFileは親アプリのファイル名)。
  • IconIndexはIconPathで指定されたファイルに含まれるアイコンのうちどれを使うかという指定ですが、1つだけなので0です。
  • WindowStyleはショートカットから起動されたときにウィンドウの状態をどうするかという指定ですが、最小化を指定しています(意図は後述)。
このShellLinkオブジェクトをSaveメソッドでスタートメニューに保存しています(ShortcutFileはショートカットのファイル名)。なお、ショートカットを置く場所の細かな違いはトーストには関係ないっぽいです。

ちなみに、AppUserModelIDには以下の命名ルールがあります。

CompanyName.ProductName.SubProduct.VersionInformation

が、実際はあまり真面目に付けられてなかったりします。例えばLenovoのユーティリティのショートカットをShellLinkクラスで見ると、いかにもサンプルのままでしたし、他のソフトもMicrosoft自身のものを含めて結構自由です。Visual Studio 2013はこんな感じ。

あまり気にしない方がいいようです。

2.5 アプリのショートカットのタイル


前述のとおりトーストの色(背景色と文字色)にはショートカットのタイルのものが利用されます。これを.VisualElementsManifest.xml拡張子を付与したファイルでカスタマイズする方法をぐらばく先生が説明されています。
このファイル(以下、マニフェストファイル)は予め作成しておいてもいいのですが、テストアプリでは色を変えたかったので実行時に作成するようにしています。で、親アプリから指示するのは背景色だけにして、文字色は子アプリの方で背景色に応じて自動選択するようにしています。

まず文字色(foregroundColor)を列挙型で定義。
private enum ForegroundColorType
{
    light,
    dark,
}

private ForegroundColorType foregroundColor;
これを背景色(backgroundColor)に応じて切り替えます。
foregroundColor = ((Color)ColorConverter.ConvertFromString(backgroundColor)).GetBrightness() < 0.5
    ? ForegroundColorType.light : ForegroundColorType.dark;
この背景色はHTMLカラーの文字列で、これをSystem.Windows.Media.Colorに変換した後、明るさをSystem.Drawing.Color.GetBrightnessメソッドに倣って作成したGetBrightness拡張メソッドで取得し、暗ければ文字色をlightに、明るければdarkにします。

ただ、実際に試してみるととくに青系の暗い色のときの結果が今一つで、アルゴリズムは改善の余地がある感じです。たぶん以下が参考になると思います。
文字色を決めた後、マニフェストファイルのXMLを組み立てて保存します。型はSystem.Xml.XmlDocumentを使っていますが、深い意味はありません。一応整形するようにしていますが、動作には関係なかったりします。
var document = new XmlDocument();

// Add Application element and set its attribute.
var applicationElement = (XmlElement)document.AppendChild(document.CreateElement("Application"));
applicationElement.SetAttribute("xmlns:xsi", @"http://www.w3.org/2001/XMLSchema-instance");

// Add VisualElements element and set its attributes.
var visualElementsElement = (XmlElement)applicationElement.AppendChild(document.CreateElement("VisualElements"));
visualElementsElement.SetAttribute("BackgroundColor", backgroundColor);
visualElementsElement.SetAttribute("ShowNameOnSquare150x150Logo", "on"); // on or off
visualElementsElement.SetAttribute("ForegroundText", foregroundColor.ToString());

// Create a manifest file (overwrite).
var targetPath = Assembly.GetExecutingAssembly().Location;
var manifestPath = Path.Combine(Path.GetDirectoryName(targetPath),
     String.Format("{0}.VisualElementsManifest.xml", Path.GetFileNameWithoutExtension(targetPath)));

using (var sw = new StreamWriter(manifestPath, false, Encoding.UTF8))
{
    var settings = new XmlWriterSettings()
    {
        OmitXmlDeclaration = true,
        Indent = true,
        NewLineOnAttributes = true,
    };

    using (var xw = XmlWriter.Create(sw, settings))
    {
        document.Save(xw);
        xw.Flush();
    }
}
また、色を変えるにはマニフェストファイルを書き換える必要がありますが、既に同じ内容のマニフェストファイルがあるか否かの判別は以下のようにしています。型はSystem.Xml.Linq.XDocumentで、またXMLの型が違いますが、こういうチェックはLINQ to XMLが楽なので。
XDocument document;

// Check and read a manifest file.
var targetPath = Assembly.GetExecutingAssembly().Location;
var manifestPath = Path.Combine(Path.GetDirectoryName(targetPath),
     String.Format("{0}.VisualElementsManifest.xml", Path.GetFileNameWithoutExtension(targetPath)));

if (!File.Exists(manifestPath))
    return false;

using (var sr = new StreamReader(manifestPath, Encoding.UTF8))
{
    document = XDocument.Parse(sr.ReadToEnd());
}

// Check Application element.
var applicatonElement = document.Elements()
    .FirstOrDefault(x => x.Name == "Application");
if (applicatonElement == null)
    return false;

// Check VisualElements elements and its attributes.
var visualElementsElement = applicatonElement.Elements()
    .FirstOrDefault(x => x.Name == "VisualElements");
if (visualElementsElement == null)
    return false;

if (!visualElementsElement.Attributes()
    .Any(x => (x.Name == "BackgroundColor") && (x.Value == backgroundColor)))
    return false;

if (!visualElementsElement.Attributes()
    .Any(x => (x.Name == "ForegroundText") && (x.Value == foregroundColor.ToString())))
    return false;

return true;
これでトーストの度に色を変えることもできるわけですが、色をトーストの要素として活用することの是非についてトースト通知のガイドラインには何も書いてないんですよね。まあ想定外なんでしょうけど。

2.6 待ち時間


アプリのインストール時や初回起動時などにショートカットを作成する場合は問題になりませんが、トースト通知を出す直前にショートカットを作成する場合、一定の時間を置かないとトーストに失敗するようです(Failedイベントが返ってくる)。この時間は環境依存かもしれませんが、自分の試した範囲では3秒が必要でした。

また、マニフェストファイルを書き換えた場合、それをショートカットのタイルに反映するにはショートカットを上書きする必要がありますが(ショートカットの設定内容はそのままでも)、この場合も新しいタイルの色がトーストにも反映されるには同じ待ち時間が必要なようです。

テストアプリでは既定を3秒として、調整できるようにしています。

2.7 トーストの設定内容と結果の伝達


親アプリからトーストの設定内容を子アプリに伝えるには、通常のコマンドのように引数オプションを使う方法でもいいですが、やや面倒なので共通の伝達用クラスを作って、これで送るようにし、結果も同じクラスで返すようにしています。

伝達用クラスはこんな感じで、各プロパティを設定後、送り側はDataContractSerializerでシリアライズして標準出力で送り、受け側は標準入力で入ってきたものをデシリアライズして使う、という流れです。
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text;

[DataContract()]
public class ToastPacket
{
    #region Property

    /// <summary>
    /// A toast's headline
    /// </summary>
    [DataMember()]
    public string Headline { get; set; }

    /// <summary>
    /// A toast's body
    /// </summary>
    [DataMember()]
    public string Body { get; set; }

    /// <summary>
    /// A toast's background color in HTML color string
    /// </summary>
    [DataMember()]
    public string BackgroundColor { get; set; }

    /// <summary>
    /// Whether a toast's duration is long
    /// </summary>
    [DataMember()]
    public bool IsLong { get; set; }

    /// <summary>
    /// Waiting time (sec) before showing a toast
    /// </summary>
    /// <remarks>This waiting time is for the case that the shortcut to child application is 
    /// newly installed or changed so that a waiting time before showing a toast is required. 
    /// The length of this waiting time seems to be a few sec in general.</remarks>
    [DataMember()]
    public int WaitTime { get; set; }

    /// <summary>
    /// Result of a toast
    /// </summary>
    [DataMember()]
    public ToastResult Result { get; set; }

    /// <summary>
    /// Note when showing a toast
    /// </summary>
    [DataMember()]
    public string Note { get; set; }

    #endregion

    #region Method

    /// <summary>
    /// Serialize this instance.
    /// </summary>
    /// <returns>Outcome string</returns>
    public string Serialize()
    {
        var serializer = new DataContractSerializer(typeof(ToastPacket));

        using (var ms = new MemoryStream())
        {
            serializer.WriteObject(ms, this);

            return Encoding.UTF8.GetString(ms.ToArray());
        }
    }

    /// <summary>
    /// Deserialize and copy to this instance.
    /// </summary>
    /// <param name="source">Source string</param>
    public void Deserialize(string source)
    {
        if (string.IsNullOrEmpty(source))
            throw new ArgumentNullException("source");

        var serializer = new DataContractSerializer(typeof(ToastPacket));

        using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(source)))
        {
            this.CopyFrom((ToastPacket)serializer.ReadObject(ms));
        }
    }

    /// <summary>
    /// Copy property values from other instance.
    /// </summary>
    /// <param name="other">Other instance</param>
    public void CopyFrom(ToastPacket other)
    {
        if (other == null)
            throw new ArgumentNullException("other");

        var properties = typeof(ToastPacket).GetProperties(BindingFlags.Public | 
                                                           BindingFlags.Instance | 
                                                           BindingFlags.Static | 
                                                           BindingFlags.DeclaredOnly);

        foreach (var p in properties)
        {
            p.SetValue(this, p.GetValue(other, null), null);
        }
    }

    #endregion
}
結果を示す列挙型はイベントに合わせたものを定義しています。
public enum ToastResult
{
    /// <summary>
    /// The user activated the toast.
    /// </summary>
    Activated,

    /// <summary>
    /// The application hid the toast using ToastNotifier.hide method.
    /// </summary>
    ApplicationHidden,

    /// <summary>
    /// The user dismissed the toast.
    /// </summary>
    UserCanceled,

    /// <summary>
    /// The toast has timed out.
    /// </summary>
    TimedOut,

    /// <summary>
    /// The toast encountered an error.
    /// </summary>
    Failed,
}
この他、ユーザーが子アプリを直接、引数オプションを付けて起動できるようにもしています。ただし、伝達用クラスは親アプリにあるものを子アプリが参照して使うようになっているので、親アプリの実行ファイルがないと子アプリ単独では動かなかったりしますが、使わなければ削ればいい話です。

2.8 親アプリの起動


トースト通知のためにスタートメニューにショートカットを置くわけですが、そういういわばアリバイのような話とは無関係に、ユーザーとしてはスタートメニューにある以上、そこから意味のあるものが起動することを期待するわけで、クリックしても適切な反応を返さないショートカットはよろしくない気がします。

というか、Windows 8以降だとスタートメニューにつまらないショートカットを作られるのは全くもって迷惑なので、存在するならせめて意味のある方がいい、さりとてトースト用アプリが起動しても嬉しくも何ともないので、テストアプリでは本体の親アプリを起動するようにしています。

ショートカットの作成時に設定したArgumentsの定数とWindowStyleの最小化はこのためのもので、Argumentsによって親アプリから起動されたものか、ユーザーから直接コマンドで起動されたものか、ショートカットから起動されたものかを判別し、ショートカットの場合はサイレントに親アプリを起動して子アプリは終了します。

ただ、そうするなら初めからショートカットのTargetPathを親アプリにしてしまうという手も考えられますが、どちらが正しいのかはよく分かりません。

3. まとめ


以上のように、.NET 4.5以降のアプリでなくてもトースト通知を使うことはできます。と言いつつ、自分が元々これを考えたのは.NET 4.0のアプリを開発していて、この対応環境からXPを外さないためには.NET 4.5に上げられなかったという理由があるのですが、気づいてみればXPのサポート期限切れまで後わずかですね。

ううむ、微妙……。

4. 最後に残った疑問


トースト通知の「トースト」の語源が分からなくて、画像検索したらこんがり焼けたトーストばかりだったので洒落のつもりでトーストのアイコンにしてみたのですが、もしかしてこれが語源……? トースターから焼き上がったトーストがポンっと出てくるのに引っ掛けた? いやいやいや……まさか、ね。

1 コメント :

RRX さんのコメント...

正式名称が Toast Popup らしいですし、
普通に下側からガチャンとポップアップするので、
語源はおっしゃる通りの意味だと思います。

ちなみに、トースト表示なのですが、
ただ表示するだけならいいのですが、
トーストにbuttonを表示するなりして、
トースト側からのアクション動作を実装する場合、
COM CLSIDをショートカットに登録することが必要になっています。

詳細
https://docs.microsoft.com/ja-jp/windows/uwp/design/shell/tiles-and-notifications/send-local-toast-desktop