KxNotifyUtils 仕様書(案A構成:C++/WinRT シム静的ライブラリ + Crystal 本体)
- 版数:v0.5(ドラフト)
- 作成日:2026-08-24
- 更新履歴:v0.2 でコードアーキテクチャ(2.1 節から 2.3 節)を追加。v0.3 で設定編集 GUI を実装対象に変更(4.8 節を新設)。v0.4 で名称を KxNotifyUtils に確定し、監視対象(ソース)と通知先(シンク)を追加できる前提へ抽象を拡張(10 章を新設)。v0.5 で配布形態をシングルバイナリに確定(シムの静的ライブラリ化、openvr_api.dll の SteamVR 側参照、libui-ng 静的リンク、vrmanifest の実行時生成)。v0.6 で設定編集 GUI に情報タブ(ライセンス表記)を追加。v0.7 で将来拡張候補に外部サービスの障害検知ソースを追記
- 対象読者:本ツールの実装者
1. 概要
KxNotifyUtils は、監視対象(ソース)から通知を集め、通知先(シンク)へ中継する Windows 常駐アプリケーションである。
v1 のソースは Windows のデスクトップ通知、シンクは XSOverlay の Notification API であり、VR セッション中でも Discord やメールなどのデスクトップ通知を HMD 内で確認できるようにすることを目的とする。
名称が示すとおり、将来はソース(VRChat ログイベントなど)とシンク(XSOverlay 以外の通知先)を追加していく前提であり、コード構成と設定スキーマはその追加をアダプタの追加だけで受けられる形にする(10 章)。
配布物は KxNotifyUtils.exe の 1 ファイルとする(シングルバイナリ配布)。
DLL の同梱も zip 展開も不要で、exe を任意の場所に置けば動く。
ただしこれは「配布物が 1 ファイル」という意味であり、設定、ログ、生成 vrmanifest は %APPDATA%\KxNotifyUtils\ に書く。SteamVR 登録が exe の絶対パスに依存する性質上、完全なポータブル運用(可搬メディアでの持ち歩き)は対象外である。
実装は次の 2 コンポーネントに分割する。
- NotifListenerShim:WinRT の
UserNotificationListener を呼び出し、フラットな C API として公開する C++/WinRT 製の静的ライブラリ(.lib)。ビルド時に本体へリンクされ、単体のファイルとしては配布されない。
- 本体(Crystal):トレイ常駐、ソースのポーリング、フィルタと整形、シンクへの送信、SteamVR スタートアップ登録を担う実行ファイル。
この分割はプロセスやファイルの分割ではなく、言語境界の分割である。
WinRT は COM ベースの ABI であり、Crystal の C FFI から直接呼ぶには HSTRING 操作や vtable 定義や非同期ハンドラ登録が必要になるため、WinRT 依存を C++/WinRT のライブラリに閉じ込め、Crystal 側は通常の C 関数呼び出しだけで済ませる。
1.1 スコープ
v1 で実装するのは次の範囲である。
- Windows 通知の取得(ポーリング方式。v1 で唯一のソース)
- 通知単位のカスタマイズ(アプリ別フィルタ、アイコン、表示時間、通知音、透明度、テキスト整形)
- XSOverlay WebSocket API への送信(UDP レガシー API はフォールバックとして規定。v1 で唯一のシンク)
- SteamVR 起動時の自動起動と SteamVR 終了時の自動終了
- トレイ常駐 UI
- 設定編集 GUI(設定ファイルの全項目を編集できるウィンドウ)
加えて、v1 の実装範囲には含まれないが、ソースとシンクの追加を将来アダプタの追加だけで行えるよう、抽象(2.3 節)と設定スキーマ(5 章)は複数ソース複数シンクの形で定義する。
追加候補は 10 章に挙げる。
次の項目はスコープ外とする。
- XSOverlay 側の表示スタイル自体の変更(XSOverlay のテーマ機能の管轄であり、送信側からは制御できない)
- localhost 以外のマシンからの通知受信(XSOverlay が localhost からの接続しか受け付けないため)
- 通知への返信やアクション実行(
UserNotificationListener は読み取り専用であるため)
1.2 前提環境
- Windows 10 バージョン 1703 以降(
UserNotificationListener の要件。実運用ターゲットは Windows 11)
- XSOverlay build 667 以降(WebSocket API 公開ビルド)
- SteamVR
- Crystal 1.x Windows 版(MSVC ツールチェーン)
- uing(libui-ng の Crystal バインディング。設定編集 GUI 用)と libui-ng の静的ライブラリ
- Visual Studio 2022 以降の C++ ビルドツールと C++/WinRT(シム静的ライブラリのビルド用)
2. システム構成
+---------------------------+
| Windows 通知基盤 (WinRT) |
| UserNotificationListener |
+------------+--------------+
| WinRT (COM ABI)
+------------v---------------------------+
| KxNotifyUtils.exe (シングルバイナリ) |
| |
| NotifListenerShim (静的リンク) |
| - アクセス許可の確認 |
| - 通知一覧の取得 |
| - アイコンの抽出 |
| - JSON へのシリアライズ |
| | C API (JSON 文字列) |
| Crystal 本体 |
| - ポーリングループ |
| - 差分検出 |
| - フィルタ / 整形 |
| - WebSocket クライアント ------------+----> ws://localhost:42070 (XSOverlay)
| - OpenVR FFI ------------------------+----> openvr_api.dll
| - トレイ UI / 設定 GUI (libui-ng 静的) | (SteamVR インストール先から動的ロード)
+----------------------------------------+
プロセスも配布ファイルも KxNotifyUtils.exe の 1 つだけである。
シムと libui-ng はリンク時に exe へ統合され、openvr_api.dll だけは同梱せず SteamVR がインストールしたものを実行時に探して使う(4.6 節)。
2.1 コードアーキテクチャの方針
Crystal 本体のコード構成は、参考実装 github_notifications_slack(GitHub 通知を Slack / Discord に中継する Crystal 製ツール)と同じクリーンアーキテクチャ風の構成を採る。
同じ「取得元から通知を取り、中立形式に変換し、差し替え可能な送信先へ送る」形の問題であり、構成をそのまま流用できるためである。
構成の規則は次のとおり。
- コンテキスト単位にディレクトリを切る。コンテキストとは、取得元(Windows 通知)、送信先(XSOverlay)、中立ドメイン(notify)など、外部境界または関心事のまとまりを指す。
- 各コンテキストは次の 3 ファイルを基本形とする。
- models:そのコンテキストのデータ型。ワイヤ形式の
JSON::Serializable 型やドメイン値を置く。
- repository:外部境界。プロセス外(シムライブラリ、WebSocket、UDP、OpenVR、設定ファイル)とのやり取りだけを担う。差し替えたい境界は abstract class で抽象を宣言し、実装は別コンテキストが継承して提供する。
- usecase:ロジック。repository をコンストラクタで受け取り(コンストラクタインジェクション)、自分では I/O を行わない。
- FFI の
lib 宣言はコンテキスト内の ffi.cr に隔離し、同じコンテキストの repository だけが参照する。usecase から上のレイヤは FFI 由来の型を見ない。
- composition root は
main.cr とする。設定の解決、アダプタの選択、usecase への依存注入をここで一度だけ行う。参考実装が NOTIFY_MODE 環境変数で Slack / Discord のアダプタを選ぶ build_poster を持つのと同型に、本ツールは設定の sinks セクションから build_sinks でシンク群を、sources セクションから build_sources でソース群を組み立てる。
参考実装との設計上の違いが 1 つある。
参考実装は送信先だけを抽象化し、取得元(GitHub)は具象のままである。
本ツールは「監視対象と通知先の両方が増える」前提のため、取得元側にも抽象 SourceRepository を置いて対称にする(2.3 節)。
notify/usecase から見ると、ソースの集合から中立形式の通知を受け取り、シンクの集合へ配る、というハブの形になる。
2.2 ディレクトリ構成
src/
main.cr # composition root(build_sources / build_sinks / DI)
notify/ # 中立ドメイン(他コンテキストに依存しない)
models.cr # Incoming: ソースに依存しない受信通知
# Message: シンクに依存しない送信通知の論理形
repository.cr # abstract SourceRepository(監視対象の抽象)
# abstract PostRepository(通知先の抽象)
usecase.cr # ポーリング 1 周期のユースケース(4.2 節、4.3 節)
win_notification/ # ソース実装:Windows 通知
ffi.cr # NotifListenerShim の lib 宣言
models.cr # WinNotification(3.3 節の JSON に対応)
repository.cr # SourceRepository 実装: シムの C API を包み、差分検出済みの
# 新規通知を Incoming で返す(4.2 節)
usecase.cr # Incoming とルールから Message を組み立てる
xsoverlay/ # シンク実装:XSOverlay
models.cr # エンベロープと通知オブジェクトのワイヤ形式
websocket_repository.cr # PostRepository 実装(WebSocket、4.4 節)
udp_repository.cr # PostRepository 実装(UDP レガシー、4.4 節)
steamvr/ # SteamVR 連携コンテキスト
ffi.cr # openvr_api の lib 宣言(4.6 節)
repository.cr # 登録操作と VREvent の取得
usecase.cr # 登録と解除、パス変更検知、終了イベント処理(4.5 節)
config/ # 設定コンテキスト
models.cr # 設定スキーマ。defaults と rules の継承解決を含む(5 章)
repository.cr # 設定ファイルの読み書き
usecase.cr # 検証、保存、再読み込み、変更の各 usecase への反映(4.8 節)
runtime/ # 実行環境(参考実装の runtime/lambda に相当)
tray.cr # Win32 トレイとメッセージループ(4.7 節)
settings_window.cr # uing による設定編集ウィンドウ(4.8 節)
scheduler.cr # ポーリングスレッドと停止制御(4.1 節)
error/
usecase.cr # 例外のログ記録とトレイ通知
ソースを追加するときは win_notification と並列に新コンテキスト(例:vrchat_log)を作り、SourceRepository を実装して build_sources に登録する。
シンクを追加するときは xsoverlay と並列に新コンテキストを作り、PostRepository を実装して build_sinks に登録する。
どちらの場合も notify と既存コンテキストの変更は不要、というのが本構成の狙いである。
参考実装との対応は次のとおり。
- github(取得元)→ win_notification(ソース実装の 1 つ目)
- slack / discord(送信先アダプタ)→ xsoverlay の websocket_repository / udp_repository(シンク実装)
- notify(中立ドメインと送信抽象)→ notify(同名同役割。ソース抽象が加わる)
- runtime/lambda(実行環境ループ)→ runtime/tray と runtime/scheduler
build_poster による送信先選択 → main.cr の build_sinks と build_sources(設定の sinks / sources セクションから組み立て)
参考実装と異なる点は 3 つある。
本ツールは常駐プロセスなので、実行環境が「Lambda の invocation ループ」ではなく「トレイのメッセージループとポーリングスレッド」になる。
取得元が HTTP API ではなく FFI なので、win_notification と steamvr に ffi.cr の層が増える。
そして 2.1 節で述べたとおり、取得元側にも抽象を置く。
2.3 依存規則
依存の向きを次のとおり固定する。
- notify は他のどのコンテキストにも依存しない。
- 中立モデルは受信側と送信側で分ける。Incoming はソースに依存しない受信通知で、
source(ソース識別子。v1 は "windows" のみ)、app_id、app_name、title、body、icon、created_at を持つ。Message はシンクに依存しない送信通知である。
Notify::Message は、どのシンクでも意味を持つコア(title、body、icon、timeout、sound、volume、source_app)と、シンクによっては解釈されない表示ヒント(height、opacity)に分けて定義する。各シンクのアダプタはコアを必ず反映し、表示ヒントは解釈できるものだけを使う(XSOverlay は両方を使う)。v0.2 では「WebSocket と UDP が同じフィールド集合を運ぶ」ことを根拠に単一の論理形としていたが、シンクが増える前提ではその根拠が成り立たないため、この 2 層に再定義する。
- ソース実装(win_notification)の repository は notify/repository の
SourceRepository を継承し、Incoming を返す。シンク実装(xsoverlay)の各 repository は PostRepository を継承し、Message を自コンテキストのワイヤ形式(xsoverlay/models)へ変換する。
- win_notification の usecase は notify/models に依存して
Incoming から Notify::Message を組み立てる(参考実装で github/usecase が Notify::Message を組み立てるのと同じ配置)。
- notify/usecase が依存するのは、
SourceRepository(抽象)の集合、win_notification/usecase、PostRepository(抽象)の集合、config/models だけとする。XSOverlay にも WinRT にも OpenVR にも依存しない。シンクへの送信は集合の全要素に同報し(fan-out)、あるシンクの送信失敗が他のシンクへの送信を妨げないようにする。
- runtime は usecase を呼び出すだけで、ロジックを持たない。
module Notify
abstract class SourceRepository
# このソースの識別子("windows" など)。Incoming#source とルールの
# マッチングに使う。
abstract def source_id : String
# 前回の呼び出し以降に発生した新規通知を返す。差分検出の方式
# (id 集合の比較、ログの追記読み、イベント購読のバッファ)は実装に閉じる。
abstract def poll_new : Array(Incoming)
end
end
この規則の目的はテスト境界の確保と、追加時の変更範囲の限定にある。
PostRepository と SourceRepository をテスト用実装に差し替えれば、notify/usecase(フィルタ、整形、fan-out の送信順序)を実 XSOverlay にも実通知にも触れず単体テストできる(9.2 節)。
またソースやシンクを追加しても、変更が入るのは新コンテキストと main.cr の組み立て、設定スキーマだけになる。
3. NotifListenerShim(C++/WinRT シム静的ライブラリ)
3.1 設計方針
- 成果物は静的ライブラリ
NotifListenerShim.lib とし、Crystal 本体のリンク時に exe へ統合する。DLL としては配布しない。
- 公開する API は C ABI のフラット関数のみとする(
extern "C"、呼び出し規約は __cdecl)。__declspec(dllexport) は不要で、Crystal 側は @[Link("NotifListenerShim")] の lib 宣言で解決する。
- 構造体のマーシャリングを避け、複合データはすべて UTF-8 の JSON 文字列で受け渡す。
Crystal 側は標準ライブラリの JSON でパースできるため、FFI 定義がポインタと整数だけで完結する。
- WinRT の非同期操作(
IAsyncOperation)はライブラリ内部で同期待ちに変換する(.get())。
ポーリング呼び出しは本体側のワーカースレッドから行う想定であり、ブロッキングしても UI には影響しない。
- ライブラリが確保した文字列バッファは、対になる解放関数でライブラリ側が解放する。
静的リンクにより CRT は本体と同一になるが、確保と解放の責務を API 境界の同じ側に置く対称性は保つ(将来 DLL 形態へ戻す場合にも API を変えずに済む)。
- C++ 例外は API 境界を越えさせない。各公開関数の内部で catch し、エラーコードと
nls_last_error に変換する。Crystal のランタイムは C++ 例外の巻き戻しを想定していないためである。
3.2 公開関数
// 初期化。RoInitialize 相当の処理を行う。
// 戻り値: 0 = 成功, 負値 = エラーコード
int32_t nls_init(void);
// 終了処理。
void nls_shutdown(void);
// 通知アクセスの許可状態を返す。
// 戻り値: 0 = Allowed, 1 = Denied, 2 = Unspecified, 負値 = エラー
int32_t nls_get_access_status(void);
// 通知アクセスの許可を要求する (RequestAccessAsync)。
// 戻り値: nls_get_access_status と同じ。
// 注意: パッケージ化されていない Win32 アプリでは Denied が返り続ける
// 既知の問題があるため、本体側は失敗時に Windows 設定画面へ誘導する。
int32_t nls_request_access(void);
// 現在の通知一覧を JSON 文字列で返す。
// 戻り値: UTF-8 JSON へのポインタ。エラー時は NULL。
// 呼び出し側は使用後に nls_free_string で解放する。
const char* nls_get_notifications(void);
// nls_get_notifications が返した文字列を解放する。
void nls_free_string(const char* p);
// 直近のエラーの詳細メッセージを返す(デバッグ用、解放不要の静的バッファ)。
const char* nls_last_error(void);
3.3 通知 JSON スキーマ
nls_get_notifications は次の形の JSON を返す。
{
"notifications": [
{
"id": 12345,
"app_id": "com.squirrel.Discord.Discord",
"app_name": "Discord",
"title": "#general | サーバー名",
"body": "メッセージ本文",
"created_at": "2026-08-24T21:03:05+09:00",
"icon_png_base64": "iVBORw0KGgo..."
}
]
}
各フィールドの取得元は次のとおり。
- id:
UserNotification.Id。差分検出のキーとして使う。
- app_id:
AppInfo.AppUserModelId。フィルタのマッチングキーとして使う。
- app_name:
AppInfo.DisplayInfo.DisplayName。
- title / body:
Notification.Visual のバインディング(ToastGeneric)からテキスト要素を取得する。
1 要素目を title、2 要素目以降を改行連結して body とする。
テキスト要素が 1 つしかない通知では body を空文字列とする。
- created_at:
UserNotification.CreationTime(ISO 8601)。
- icon_png_base64:
AppInfo.DisplayInfo.GetLogo() から取得したロゴを PNG にデコードして base64 化したもの。
取得に失敗した場合はフィールドごと省略する。
アイコンの取得と base64 化は通知ごとに行うとコストが高いため、シムライブラリ内部で app_id をキーにキャッシュする。
キャッシュはプロセス生存期間中保持し、上限は設けない(常駐アプリの実用上、通知を出すアプリ数は高々数十である)。
3.4 スレッドと COM アパートメント
nls_init を呼んだスレッドと同じスレッドから全 API を呼ぶことを契約とする(MTA で初期化する)。
本体側はポーリング専用のワーカースレッドを 1 本立て、そのスレッドにシム API の呼び出しを閉じ込める。
4. 本体(Crystal)
本章の各節は 2.2 節のモジュールに対応する。
各節の冒頭に担当モジュールを示す。
4.1 ライフサイクル
担当:main.cr(起動シーケンスと DI)、runtime/scheduler.cr(ポーリングスレッド)、runtime/tray.cr(メッセージループ)。
起動から終了までの流れは次のとおり。
- 設定ファイルを読み込む(存在しなければデフォルト設定を生成する)。
- 設定の sources / sinks セクションから
build_sources と build_sinks でアダプタ群を組み立てる(v1 は Windows 通知ソースと XSOverlay シンク)。Windows 通知ソースの初期化で nls_init を呼ぶ(シムは exe に静的リンク済みのため、ロード処理は無い)。
- 通知アクセス許可を確認する。未許可なら許可要求を行い、失敗した場合はトレイ通知で Windows 設定画面(
ms-settings:privacy-notifications)への誘導を表示する。許可が得られるまで Windows 通知ソースの中継は行わないが、プロセスは常駐を続ける。
- openvr_api.dll を SteamVR インストール先から解決してロードし(4.6 節)、OpenVR を
VRApplication_Overlay で初期化する。初回起動時は vrmanifest の生成と登録、自動起動設定を行う(4.5 節)。
- 各シンクの接続を開始する(XSOverlay は WebSocket 接続。4.4 節)。
- ポーリングループを開始する。
- 次のいずれかで終了する。
- OpenVR の
VREvent_Quit を受信した(SteamVR 終了)。
- トレイメニューから終了が選択された。
WM_QUERYENDSESSION を受けた(Windows シャットダウン)。
SteamVR が起動していない状態で手動起動された場合、OpenVR 初期化は失敗する。
このとき本体は初期化を諦めて常駐を続け、60 秒間隔で OpenVR 初期化を再試行する。
これにより「先にツールを起動し、後から SteamVR を起動する」順序でも終了連動が機能する。
4.2 ポーリングと差分検出
担当:ループの駆動と各ソースの poll_new 呼び出しは notify/usecase.cr、Windows 通知の差分検出は win_notification/repository.cr(SourceRepository 実装)。
v0.3 までは差分検出を notify/usecase の責務としていたが、id 集合の比較は Windows 通知というソースに固有の検出方式であり、ソースが増えたとき(ログの追記読みやイベント購読など検出方式はソースごとに違う)に中立の usecase へソース固有知識が積み上がるため、poll_new の契約(2.3 節)に含めてソース実装側へ移した。
usecase は各ソースから受け取った Incoming を中継パイプライン(4.3 節)に流すだけで、FFI にも検出方式にも触れない。
win_notification の poll_new は次のとおり動く。
- ポーリング間隔はデフォルト 500 ms とし、設定で 100 ms から 5000 ms の範囲で変更できる。
- 呼び出しごとに
nls_get_notifications を呼び、返ってきた id の集合を前回の集合と比較する。
- 前回に存在せず今回存在する
id を新規通知とみなし、Incoming(source: "windows")へ変換して返す。
- 初回の呼び出し結果は既読とみなし、空を返す。
起動前から通知センターに溜まっていた通知を一斉送信しないためである。
- 前回に存在し今回存在しない
id は単に集合から除去する(削除された通知の追跡は行わない)。
この方式で拾えるのは通知センターに到達した通知だけである。
トーストが画面に表示されない設定(フォーカスアシスト有効時や、アプリ側でバナー非表示のもの)でも通知センターに入れば取得できるが、通知センター自体に入らない通知は取得できない。
この制約は README とセットアップガイドに明記する(7 章)。
4.3 中継パイプライン(フィルタと整形)
担当:手順 1 と手順 7 の送信呼び出しは notify/usecase.cr、手順 2 のルール解決(defaults との継承マージ)は config/models.cr、手順 3 から手順 7 の Message 組み立ては win_notification/usecase.cr。
参考実装で github/usecase の build_message が「取得元の通知 + 付随情報 → Notify::Message」の変換を一手に担うのと同様に、整形の知識はソース側の usecase に集約し、notify/usecase はパイプラインの順序制御と fan-out だけを持つ。
将来ソースを追加する際も、そのソースの Incoming から Message を組み立てる usecase を新コンテキスト内に持たせる(整形ロジックを notify に集めない)。
新規通知(Incoming)1 件ごとに、次の順で処理する。
- フィルタ判定:設定の
filter.mode(blacklist または whitelist)と filter.apps に従い、app_id の前方一致で中継可否を決める。
- ルール解決:
rules 配列を上から順に評価し、app_id が最初にマッチしたルールを採用する。マッチしない場合は defaults を使う。ソースが増えた際は、マッチ条件に match_source を追加してソース別ルールを書けるようにする(v1 ではソースが 1 つなので実装しない。5 章の注記参照)。
- テキスト整形:
- title テンプレート(例
"{app_name}: {title}")を適用する。
- body が
max_body_length(デフォルト 200 文字)を超える場合は切り詰めて末尾に … を付ける。
- 表示時間の決定:
timeout_mode が fixed なら timeout 秒をそのまま使う。dynamic なら次式で計算する。
timeout = clamp(base + 文字数 / reading_speed, min, max)
文字数は title と body の合計とする。
デフォルトは base 2.0 秒、reading_speed 12 文字/秒、min 3.0 秒、max 15.0 秒とする(日本語の黙読速度の実用値として仮置きし、実測で調整する)。
5. 表示ヒントの決定:height は body が空なら 100、あれば文字数に応じて 120 から 250 の範囲で線形に決める(実機で調整する)。opacity はルールから引く。どちらも Message の表示ヒント(2.3 節)であり、解釈しないシンクは無視してよい。
6. アイコンの決定:ルールの icon 設定に従う。
"app"(デフォルト):シムが返した icon_png_base64 を使う。無ければ "default" にフォールバックする。
"default" / "warning" / "error":組み込みアイコンを指すシンボル名として Message にそのまま載せる(XSOverlay はこの 3 名を組み込みアイコンとして解釈する。他のシンクを追加する際は各アダプタが自分の同等物へ対応づける)。
- ファイルパス:指定 PNG を読み込んで base64 化して使う。
- Message の構築:ここまでの結果からコアと表示ヒント(2.3 節)を持つ
Notify::Message を組み立て、全シンクへ渡す(4.4 節)。volume と sound(audioPath に対応)はルールから引き、sound は "default" / "warning" / "error" / 音声ファイルパス / 空文字列(ミュート)を許容する。
カスタマイズ可能な範囲を Message のフィールド(実質は XSOverlay の通知オブジェクトのフィールド集合)に限定していることが、本ツールの設計上の割り切りである。
通知の配置やフォントなど、フィールドに存在しない見た目は各シンク側アプリケーションの設定の管轄とする。
4.4 シンクへの送信
担当:抽象は notify/repository.cr の PostRepository、v1 の実装は xsoverlay/websocket_repository.cr と xsoverlay/udp_repository.cr。
どのシンクを有効にするかは main.cr の build_sinks が設定の sinks セクション(5 章)から決め、想定外の値は起動時に例外にする(参考実装の NOTIFY_MODE 分岐と同じ扱い。設定ミスのまま黙って動かさない)。
notify/usecase は PostRepository の集合だけを知り、Message を全シンクへ同報する。あるシンクが送信に失敗しても他のシンクへの送信は続け、失敗はシンクごとにログへ残す。
接続と再接続とワイヤ形式変換はアダプタに閉じる。
module Notify
abstract class PostRepository
# シンクの識別子("xsoverlay" など)。ログとテスト通知の宛先表示に使う。
abstract def sink_id : String
# Message を送信する。接続断などで送信できなかった場合は false を返す
# (呼び出し側は破棄する。本節の「キューしない」方針)。
abstract def send_message(message : Message) : Bool
end
end
XSOverlay アダプタの仕様は次のとおり。
- WebSocket 実装を第一とする。
接続先は ws://localhost:{port}/?client=KxNotifyUtils で、port のデフォルトは 42070 とする。
client クエリパラメータが無いと XSOverlay 側が接続を拒否するため、必ず付与する。
Message を XSOverlay の通知オブジェクトへ変換し、エンベロープに包んで送る。
{
"sender": "KxNotifyUtils",
"target": "xsoverlay",
"command": "SendNotification",
"jsonData": "{\"type\":1,\"title\":\"...\",\"content\":\"...\",\"timeout\":6.5,\"height\":175.0,\"opacity\":1.0,\"volume\":0.7,\"audioPath\":\"default\",\"useBase64Icon\":true,\"icon\":\"...\",\"sourceApp\":\"KxNotifyUtils\"}",
"rawData": null
}
変換は素直なフィールド対応(body → content、sound → audioPath、base64 アイコンなら useBase64Icon: true)で、表示ヒントの height と opacity もそのまま載せる。
- WebSocket クライアントは Crystal 標準ライブラリの
HTTP::WebSocket を使う(外部 shard 不要)。
- 切断を検知したら指数バックオフ(1, 2, 4, ... 最大 30 秒)で再接続する。
- 未接続の間に発生した通知はキューせず破棄する。
XSOverlay が動いていない状況とは VR 外である可能性が高く、そのとき通知は通常のトーストで見えているため、後からまとめて HMD に流す価値がない。
- sinks 設定で
transport を "udp" にした場合はレガシー UDP API(127.0.0.1:42069、デフォルト)に同じ通知オブジェクトを直接送る。
WebSocket に問題が出た場合の退避手段としてのみ残し、ドキュメント上は非推奨とする。
4.5 SteamVR スタートアップ登録
担当:steamvr/usecase.cr(vrmanifest の生成、登録判断、パス変更検知)と steamvr/repository.cr(OpenVR 呼び出し)。
vrmanifest は配布物に含めず、実行時に生成する。
起動時に自分の exe の絶対パスを binary_path_windows に書き込んだ次の内容を %APPDATA%\KxNotifyUtils\kxnotifyutils.vrmanifest へ書き出す。
{
"source": "builtin",
"applications": [{
"app_key": "kairo.kxnotifyutils",
"launch_type": "binary",
"binary_path_windows": "<起動時に解決した exe の絶対パス>",
"is_dashboard_overlay": true,
"strings": {
"en_us": { "name": "KxNotifyUtils", "description": "Relays notifications to XSOverlay." },
"ja_jp": { "name": "KxNotifyUtils", "description": "通知を XSOverlay に中継します。" }
}
}]
}
生成先を exe の隣ではなく %APPDATA% に固定するのは、SteamVR に渡すマニフェストのパスを exe の置き場所から独立させるためである。
exe を移動しても SteamVR が参照するマニフェストパスは変わらず、マニフェストの中身(binary_path_windows)を書き換えるだけで追従できる。
登録の手順は次のとおり。
- 初回起動時(および設定でスタートアップ登録が有効化されたとき)に、vrmanifest を生成してから OpenVR FnTable 経由で次を呼ぶ。
IVRApplications::AddApplicationManifest(生成した vrmanifest の絶対パスを渡す)
IVRApplications::SetApplicationAutoLaunch("kairo.kxnotifyutils", true)
- 毎起動時に、前回登録時の exe パス(設定ファイルの
last_exe_path に記録)と現在の exe パスを比較する。異なる場合は exe が移動されたとみなし、vrmanifest を再生成して AddApplicationManifest を再実行する(SteamVR 側のキャッシュを確実に更新するため、マニフェスト内容の書き換えだけで済ませず再登録まで行う)。
- 登録解除は
SetApplicationAutoLaunch(..., false) と RemoveApplicationManifest で行い、トレイメニューから実行できるようにする。解除時に生成した vrmanifest ファイルも削除する。
4.6 OpenVR FFI(Crystal 側最小バインディング)
担当:steamvr/ffi.cr。関数ポインタ型と FnTable の構造体定義をここに閉じ、参照するのは steamvr/repository.cr だけとする。
openvr_api.dll は同梱せず、SteamVR がインストールしたものを実行時に探して動的ロードする。
openvr_api.dll は実体である SteamVR ランタイムへの薄いローダーであり、SteamVR と同時に配布・更新されるものを使えば、同梱版の陳腐化(古い openvr_api を配り続ける状態)を避けられる。
解決の手順は次のとおり。
%LOCALAPPDATA%\openvr\openvrpaths.vrpath を読む。これは OpenVR 標準のパス設定ファイル(JSON)で、runtime 配列に SteamVR ランタイムのインストール先が入っている。
runtime の各エントリに対し <runtime>\bin\win64\openvr_api.dll の存在を確認し、最初に見つかったものを LoadLibraryW でロードする。
- 使用する関数(下記)を
GetProcAddress で解決する。静的リンクやインポートライブラリは使わないため、Crystal 側の lib 宣言は関数ポインタ型の定義だけになる。
- ファイルが見つからない、またはロードに失敗した場合は SteamVR 連携(登録と終了イベント)を無効にして常駐を続け、4.1 節の 60 秒間隔の再試行でパス解決からやり直す。SteamVR 未インストール環境でも本体は起動できる。
GetProcAddress で解決する関数は次のとおり。
VR_InitInternal2 / VR_ShutdownInternal
VR_IsInterfaceVersionValid
VR_GetGenericInterface("FnTable:IVRApplications_007" と "FnTable:IVRSystem_022" の取得に使う)
IVRApplications の AddApplicationManifest / RemoveApplicationManifest / SetApplicationAutoLaunch / GetApplicationAutoLaunch / IsApplicationInstalled(FnTable 経由)
IVRSystem::PollNextEvent(VREvent_Quit の検知用。FnTable 経由)
インターフェースバージョン文字列はビルド時に参照する openvr ヘッダの版に合わせて確定し、起動時に VR_IsInterfaceVersionValid で検証する。
SteamVR 側の openvr_api.dll は後方互換に保たれているため、本ツールが要求する版が古い分には問題にならない。検証に失敗した場合(想定外に古い SteamVR など)は SteamVR 連携を無効にしてログに残す。
VREvent_Quit を受信したら IVRSystem::AcknowledgeQuit_Exiting を呼んでから終了処理に入る。
4.7 トレイ UI
担当:runtime/tray.cr。メニュー項目のハンドラは対応する usecase(中継の一時停止は notify、登録と解除は steamvr、再読み込みは config)を呼ぶだけで、tray 自身はロジックを持たない。
GUI ツールキットを使わず、Win32 API 直叩き(Shell_NotifyIcon とメッセージウィンドウ)で実装する。
表示するメニューは次の項目に限定する。
- 中継の一時停止 / 再開(チェック項目)
- テスト通知を送信(疎通確認用)
- 設定(設定編集ウィンドウを開く。4.8 節)
- 設定ファイルを開く(既定のエディタで JSON を開く。手動編集の逃げ道として残す)
- 設定を再読み込み(手動編集後の反映用)
- SteamVR 自動起動の登録 / 解除
- ログフォルダを開く
- 終了
4.8 設定編集 GUI
担当:runtime/settings_window.cr(画面)と config/usecase.cr(検証、保存、反映)。
2.3 節の規則どおり、settings_window は入力値の受け渡しと表示だけを行い、検証と保存と反映のロジックは config/usecase に置く。
GUI ツールキットには uing(libui-ng の Crystal バインディング)を使う。
トレイ(Win32 直叩き)と混在するが、libui-ng のメインループも同一スレッドの Win32 メッセージポンプであるため、UI スレッドを 1 本に統一すればトレイのメッセージウィンドウと共存できる見込みである。
この共存は実装初期にプロトタイプで検証し、成立しない場合は設定ウィンドウを専用 UI スレッドに分離する(未決事項)。
ワーカースレッド(ポーリング、OpenVR イベント)から UI への操作は uiQueueMain 相当の仕組みで UI スレッドに転送する。
4.8.1 画面構成
シングルトンウィンドウとし、トレイメニューの「設定」で開く(既に開いていれば前面化する)。
タブで次の 7 画面に分ける。
監視対象タブと通知先タブは設定スキーマの sources / sinks セクション(5 章)にそのまま対応し、将来のソースやシンクはこの 2 タブ内の項目として増える。
- 全般:ログレベルなど、ソースにもシンクにも属さない項目。
- 監視対象:sources の各ソースの有効 / 無効と個別設定。v1 は Windows 通知のみで、有効スイッチ、ポーリング間隔、通知アクセス許可の状態表示(未許可なら Windows 設定画面を開くボタン)を置く。
- 通知先:sinks の各シンクの有効 / 無効と個別設定。v1 は XSOverlay のみで、有効スイッチ、transport(websocket / udp)、各ポートを置く。
- 既定の通知設定:defaults の全項目(timeout_mode と dynamic_timeout の各係数、max_body_length、title_template、icon、opacity、volume、sound)。timeout_mode に応じて fixed / dynamic の入力欄を切り替える。
- アプリ別ルール:rules の一覧と編集。左にルール一覧(
match_app_id を表示)、右に選択中ルールの編集フォームを置く。フォームは defaults と同じ項目に「この項目を上書きする」チェックを付けた形とし、チェックが外れた項目は defaults 継承(JSON 上はフィールド省略)になる。ルールの追加、削除、並べ替え(先勝ちマッチのため順序に意味がある)を提供する。match_app_id の入力補助として、直近のポーリングで観測した app_id と app_name の一覧から選択できるようにする。フィルタ(mode と apps の一覧編集)もこのタブに含め、同じ選択補助を付ける。
- SteamVR:自動起動の登録状態表示と、登録 / 解除ボタン。
- 情報:アプリ名とバージョン、リポジトリ URL(既定のブラウザで開くリンク)、およびサードパーティライセンス表記。ライセンス表記は 8.1 節でリソース埋め込みする集約テキスト(THIRD-PARTY-NOTICES)を読み取り専用の複数行テキスト欄に全文表示し、あわせて各依存のリポジトリへのリンクを置く。シングルバイナリ配布では同梱の LICENSE ファイルが存在しないため、この画面が利用者から見えるライセンス表記の一次的な置き場所になる(Release 本文と exe のバージョン情報は補助)。
各タブ共通で、ウィンドウ下部に「テスト通知を送信」「保存」「閉じる」を置く(情報タブは表示のみで、保存対象の項目を持たない)。
テスト通知は現在編集中(未保存)の defaults を使って有効な全シンクへ送信し、設定を保存せずに見た目と音を確認できるようにする。
4.8.2 保存と反映
- 「保存」で config/usecase の検証を通し、検証エラー(数値範囲外、
sound のファイル不存在、match_app_id の空文字列など)は該当タブを示してダイアログで表示する。保存は全項目が有効なときだけ行う。
- 検証を通った設定は JSON ファイル(5 章のスキーマ)へ書き出し、同時に各 usecase へ即時反映する。反映は設定スナップショット(immutable な
Config 値)の差し替えで行い、ポーリング周期の途中で新旧設定が混ざらないようにする。
- GUI とファイルは同一スキーマの同一ファイルを操作する。外部エディタでの編集も引き続き有効であり、「設定を再読み込み」で取り込める。設定ウィンドウを開いている間に再読み込みが行われた場合、未保存の編集内容は破棄せず、ウィンドウ内に「ファイルが変更されています」の表示だけを出して利用者に選ばせる。
- ウィンドウを「閉じる」で未保存の変更がある場合は、保存するか破棄するかを確認する。
5. 設定ファイル仕様
配置場所は %APPDATA%\KxNotifyUtils\config.json とする。
最上位を sources(監視対象ごとの設定)、sinks(通知先ごとの設定)、それらに横断的な filter / defaults / rules / steamvr に分ける。
sources と sinks はソース識別子とシンク識別子をキーとするオブジェクトで、ソースやシンクを追加したときは新しいキーを足すだけで済み、既存キーの互換が保たれる。
{
"sources": {
"windows": {
"enabled": true,
"polling_interval_ms": 500
}
},
"sinks": {
"xsoverlay": {
"enabled": true,
"transport": "websocket",
"websocket_port": 42070,
"udp_port": 42069
}
},
"filter": {
"mode": "blacklist",
"apps": ["Microsoft.Windows.Explorer"]
},
"defaults": {
"timeout_mode": "dynamic",
"timeout": 6.0,
"dynamic_timeout": { "base": 2.0, "reading_speed": 12, "min": 3.0, "max": 15.0 },
"max_body_length": 200,
"title_template": "{app_name}: {title}",
"icon": "app",
"opacity": 1.0,
"volume": 0.5,
"sound": "default"
},
"rules": [
{
"match_app_id": "com.squirrel.Discord",
"volume": 0.8,
"sound": "C:/sounds/discord.wav"
},
{
"match_app_id": "Microsoft.OutlookForWindows",
"timeout_mode": "fixed",
"timeout": 10.0,
"icon": "warning",
"volume": 0.0
}
],
"steamvr": {
"auto_launch_registered": true,
"last_exe_path": "D:/tools/KxNotifyUtils.exe"
},
"log_level": "info"
}
rules の各項目は defaults との差分だけを書けばよい(未指定フィールドは defaults を継承する)。
rules のマッチ条件は v1 では match_app_id のみとする。ソース追加時に match_source を追加してソース別ルールを書けるようにする予定であり、match_ プレフィックスはその拡張のために予約する。
- 全シンクが無効(sinks のすべてが
enabled: false)の設定は検証エラーとする。中継先が無い常駐は設定ミスである可能性が高いためである。
- 不正な JSON を読んだ場合は直前の有効な設定で動作を続け、トレイ通知でエラーを知らせる。
6. ログ
- 出力先は
%APPDATA%\KxNotifyUtils\logs\ とし、日次ローテーションで 7 世代保持する。
info では起動と終了、各シンクの接続と切断、登録操作、中継件数(ソース別とシンク別の 1 分ごとの集計)を記録する。
debug では中継した通知の source と app_id と title を記録する。body は記録しない(通知本文は個人情報を含みやすいため、デバッグレベルでも残さない)。
7. 既知の制約(ドキュメント記載必須事項)
利用者向けドキュメントに次を明記する。
- 通知アクセス許可が必要である。
Windows 設定 > プライバシーとセキュリティ > 通知 で本アプリに通知へのアクセスを許可する必要がある。
パッケージ化されていないアプリではアプリ内からの許可要求が失敗することがあり、その場合は設定画面から手動で許可する。
- 通知センターに入らない通知は中継できない。
フォーカスアシスト(応答不可モード)や、アプリ側の通知バナー設定によって抑制された通知は取得できないことがある。
VR ゲームがフルスクリーン最適化で通知を抑制するケースが典型であり、確実に中継したいアプリはフォーカスアシストの優先リストに入れることを推奨する。
- XSOverlay は localhost からの接続しか受け付けない。
別マシンの通知を中継する構成は組めない。
- 表示スタイルの大枠は XSOverlay 側の設定に従う。
本ツールが制御できるのはアイコン、表示時間、高さ、透明度、通知音、テキスト内容である。
- 実行ファイルの移動には自動で追従するが、SteamVR の再起動が必要になることがある。
exe を移動すると次回起動時に vrmanifest を再生成して再登録する(4.5 節)。SteamVR 起動中に移動した場合、SteamVR 側が新しいパスを認識するのは再登録後になるため、自動起動が一度失敗することがある。
8. ビルドと配布
配布物は KxNotifyUtils.exe の 1 ファイルである(1 章)。
これを成立させるためのリンク構成を本章で定める。
8.1 ビルドとリンク構成
- シム:Visual Studio の C++/WinRT プロジェクトを静的ライブラリ構成でビルドする(x64、
/std:c++20)。成果物は NotifListenerShim.lib。
- libui-ng:静的ライブラリとしてビルド(または静的ビルド済み成果物を取得)し、uing 経由でリンクする。
- CRT の統一:シム、libui-ng、Crystal 本体のすべてを同一の CRT リンク設定に揃える。静的 CRT(
/MT)で統一できるかをビルド整備の最初に検証し、リンク衝突(defaultlib の混在)が解消できない場合は動的 CRT(/MD)で統一する。/MD の場合、Windows 10 以降は UCRT が OS 同梱のため配布物は増えないが、vcruntime への依存が残る可能性を README に記載する。
- 本体:Crystal Windows 版(MSVC ツールチェーン)で
crystal build --release し、--link-flags で NotifListenerShim.lib、libui-ng の .lib、および必要な Windows システムライブラリ(windowsapp.lib など C++/WinRT が要求するもの)を渡す。openvr は実行時ロード(4.6 節)のためリンク対象に含めない。
- リソース埋め込み:次を .res としてリンクし、外部ファイルを無くす。
- アプリケーションマニフェスト:Common Controls v6 の依存宣言(libui-ng の動作要件)と DPI awareness(PerMonitorV2)を含める。
- アプリアイコン(トレイと exe 用)。
- THIRD-PARTY-NOTICES:静的リンクまたはコード同梱する依存(openvr ヘッダ、libui-ng、uing、C++/WinRT、Crystal ランタイムと標準ライブラリ、Boehm GC や pcre2 などの Crystal 同梱依存)のライセンス全文を 1 ファイルに集約したテキスト。各依存の実ライセンス種別はビルド整備時に確認して集約し、依存を追加したらこのファイルの更新をリリース手順に含める。設定 GUI の情報タブ(4.8.1 節)がこれを表示する。
- テスト通知用アイコンなど、実行時に必要な小さな静的アセット。
- CI は GitHub Actions の windows ランナーでシムと本体をビルドし、
crystal spec(9.2 節の単体テスト)を通してから exe 単体を成果物にする。
単体テストは外部依存を持たない(2.3 節)ため、ubuntu ランナーでも実行できるが、FFI 宣言のコンパイル確認を兼ねて windows ランナーで実行する。
8.2 配布物の構成
配布は exe 1 ファイルのみとし、インストーラも zip も作らない。
README や LICENSE はリポジトリと GitHub Release の本文で提供し、サードパーティライセンス表記は exe に埋め込んだ THIRD-PARTY-NOTICES(8.1 節)を設定 GUI の情報タブで表示するものを一次とする。openvr ヘッダ由来のコードを含むため BSD-3 の表記もそこに含め、Release 本文と exe のバージョン情報にも併記する。
設置先は任意で、exe を移動しても SteamVR 登録は次回起動時に追従する(4.5 節)。
9. テスト計画
9.1 シム静的ライブラリ単体
シムは exe に統合されるため、テストは C API を呼ぶ小さなテストハーネス exe(C++ 側のプロジェクトに同居させ、NotifListenerShim.lib をリンクしたもの)で行う。配布物ではない。
- 通知アクセス許可の各状態(Allowed / Denied / Unspecified)で
nls_get_access_status が正しい値を返すこと。
- 通知が 0 件、複数件、アイコン取得不能なアプリの通知、テキスト要素が 1 つだけの通知、で JSON が仕様どおりであること。
nls_get_notifications を 10,000 回連続で呼んでメモリリークがないこと(アイコンキャッシュ分の増加を除く)。
- C++ 例外が API 境界を越えず、エラーコードと
nls_last_error に変換されること(3.1 節)。
9.2 Crystal usecase 単体(crystal spec)
2.3 節のテスト境界を使い、外部依存なしで検証する。
SourceRepository と PostRepository はテスト用実装(返す Incoming を差し込め、送信された Message を記録するもの)に差し替える。
差分検出は win_notification 側へ移した(4.2 節)ため、そのテストはシム API の呼び出し部分だけをテスト用に差し替えた SourceRepository 実装に対して行う。
- notify/usecase:各ソースの
poll_new の結果がすべてパイプラインに乗ること。フィルタの blacklist / whitelist が app_id 前方一致で効くこと。複数シンクへの fan-out で、1 つのシンクの送信失敗(send_message が false)が他のシンクへの送信を妨げないこと。失敗した通知はキューされず破棄されること。
- win_notification/repository(差分検出):初回の
poll_new が空を返すこと。2 回目以降で新規 id の通知だけが Incoming として返ること。消えた id が集合から除去されること。
- win_notification/usecase:title テンプレートの展開、body の切り詰め、dynamic timeout の計算と min / max クランプ、アイコンのフォールバック順序(app → default)。
- config/models:rules と defaults の継承マージ。未指定フィールドが defaults を継承し、指定フィールドだけが上書きされること。不正 JSON で直前の有効な設定が維持されること。sources / sinks に未知のキーがあっても既知のキーの読み込みに影響しないこと(前方互換)。
- config/usecase:検証(数値範囲、ファイル存在、空の
match_app_id、全シンク無効)が仕様どおりに弾くこと。保存でスナップショットが差し替わり、旧スナップショットを参照中の処理に影響しないこと。
9.3 結合
- テスト通知(トレイメニュー)が XSOverlay に表示されること。
- Discord、Slack、Outlook、LINE の実通知で title と body とアイコンが期待どおりであること。
- XSOverlay を落として再起動したとき、再接続して中継が復帰すること。切断中の通知が破棄されること。
- ルールの継承(defaults との差分マージ)が仕様どおりであること。
- dynamic timeout の計算が min / max でクランプされること。
- 設定ウィンドウで編集して保存した内容が、JSON ファイルと実際の中継動作の両方に反映されること。未保存のままテスト通知を送ると編集中の値で表示されること。
- 設定ウィンドウとトレイが同時に動作すること(メニュー操作でウィンドウ側が固まらないこと、ポーリングが継続すること)。
- 外部エディタで JSON を書き換えて「設定を再読み込み」したとき、開いている設定ウィンドウに変更検知の表示が出ること。
- 情報タブに埋め込みの THIRD-PARTY-NOTICES 全文が表示され、リポジトリリンクが既定のブラウザで開くこと。
9.4 SteamVR 連動
- openvrpaths.vrpath から openvr_api.dll が解決されること。SteamVR 未インストール環境(またはファイル改名で疑似再現)で、SteamVR 連携が無効のまま本体が起動と常駐を続けること。
- SteamVR 起動で本体が自動起動すること。
- SteamVR 終了で本体が終了すること(
VREvent_Quit 経路と、SteamVR 側の起動阻害が起きないこと)。
- SteamVR より先に本体を手動起動した場合でも、後から起動した SteamVR の終了に連動すること(60 秒再試行の確認)。
- 実行ファイルを別フォルダに移動して起動したとき、vrmanifest が再生成と再登録され、次回の SteamVR 起動で新パスの exe が自動起動すること。
10. 将来拡張の方針と候補
ソースとシンクの追加手順は 2.2 節末尾のとおり(新コンテキスト + main.cr の登録 + 設定キーの追加)であり、本章はその受け皿で何を追加しうるかの候補を挙げる。
いずれも本仕様の実装対象ではなく、着手時に個別の仕様を起こす。
ソースの候補:
-
VRChat ログイベント:VRChat のログファイルを追記読みし、ワールド移動、フレンドの join / leave、招待などを通知にする。既存ツール XSOverlay-VRChat-Parser と同領域だが、本ツールに載せれば defaults / rules による整形と将来の複数シンクを共有できる。
-
外部サービスの障害検知:VRChat、YouTube、Steam、Discord の障害情報を通知にする。各サービスの公開ステータスページ(VRChat と Discord は Statuspage 形式の JSON API を公開している。Steam と YouTube は公式のプログラム向けステータス提供が限定的なため、着手時に取得元を選定する)を一定間隔でポーリングし、稼働状態が正常から変化したときだけ Incoming を発行する。VR プレイ中に「VRChat 側の障害なのか自環境の問題なのか」を切り分けられるのが主目的で、VRChat 単体では既存ツール XSOverlay-VRChat-Status と同領域になる。状態変化の検出(前回状態との比較)はソース実装に閉じる設計(2.3 節の poll_new の契約)にそのまま乗る。
-
スマートフォン通知:別デバイスからの受信はネットワーク受け口が必要になり、本体をサーバー化する設計変更を伴うため、候補ではあるが優先度は低い。
シンクの候補:
-
Discord webhook:VR 外や離席中にも記録が残る通知先。Message のコアだけで表現でき、表示ヒントは無視される(2.3 節の 2 層定義が最初に効く例になる)。
-
OSC(VRChat チャットボックスなど):短文のコアのみを流す縮退表示。
-
他のオーバーレイ通知 API:OVR Toolkit 等、対応 API を持つオーバーレイが対象になりうる。
拡張時に再検討が必要になる既知の論点も残しておく。
filter と rules は現状 app_id 前提であり、ソースが増えた時点で match_source の追加(5 章の予約)と、ソース別の観測補助(GUI の選択リスト)の拡張が必要になる。
またシンクが増えた場合、ルールで「この通知はこのシンクだけへ送る」というルーティングの要望が出ることが予想されるが、設定の複雑さと引き換えになるため、必要になった時点で rules への sinks フィールド追加として設計する。
11. 未決事項
- CRT リンク設定の確定:シム、libui-ng、Crystal 本体の CRT を
/MT で統一できるか(8.1 節)。Crystal の標準リンク構成との衝突が解消できない場合は /MD 統一へ切り替える。ビルド整備の最初に検証する。
- uing の静的リンク対応:uing が libui-ng の静的 .lib へのリンクをそのまま受け付けるか。受け付けない場合は shard の設定の上書き、それでも不可なら fork でリンク指定を変更する(DLL 同梱へは戻さない)。
- libui-ng メインループとトレイの共存:4.8 節の「UI スレッド 1 本での共存」は見込みであり、実装初期のプロトタイプで検証する。成立しない場合は設定ウィンドウを専用 UI スレッドに分離し、スレッド間の設定受け渡しをスナップショット経由に限定する。
- openvrpaths.vrpath の形式差:
runtime 配列の内容は環境(SteamVR の複数バージョン併存など)で差がありうるため、実環境のファイルを確認して 4.6 節の解決手順のフォールバック(Steam のレジストリからの steamapps\common\SteamVR 解決を足すか)を判断する。
- アプリ別ルールタブの一覧ウィジェット:libui-ng の Table を使うか、リストボックスと編集フォームの組み合わせにするかは、uing の Table 対応状況を見て決める。
- アイコンの色形式:
GetLogo() が返すロゴの形式はアプリによって差があり、透過や余白の見え方は XSOverlay 実機で確認して調整する。
- 通知の重複:同一アプリが同内容の通知を短時間に連投した場合の抑制(デデュープ)を入れるか。v1 では入れず、実使用で必要性を判断する。
- height の算出式:4.3 節の線形式は仮であり、XSOverlay の実表示を見て係数を確定する。
- IVRApplications のインターフェースバージョン:ビルドに参照する openvr ヘッダ確定時に固定する。
- メディア通知の扱い:XSOverlay の messageType 2(Media Player)連携は本仕様の対象外としたが、将来の拡張候補として挙げておく。
参考資料
KxNotifyUtils 仕様書(案A構成:C++/WinRT シム静的ライブラリ + Crystal 本体)
1. 概要
KxNotifyUtils は、監視対象(ソース)から通知を集め、通知先(シンク)へ中継する Windows 常駐アプリケーションである。
v1 のソースは Windows のデスクトップ通知、シンクは XSOverlay の Notification API であり、VR セッション中でも Discord やメールなどのデスクトップ通知を HMD 内で確認できるようにすることを目的とする。
名称が示すとおり、将来はソース(VRChat ログイベントなど)とシンク(XSOverlay 以外の通知先)を追加していく前提であり、コード構成と設定スキーマはその追加をアダプタの追加だけで受けられる形にする(10 章)。
配布物は KxNotifyUtils.exe の 1 ファイルとする(シングルバイナリ配布)。
DLL の同梱も zip 展開も不要で、exe を任意の場所に置けば動く。
ただしこれは「配布物が 1 ファイル」という意味であり、設定、ログ、生成 vrmanifest は
%APPDATA%\KxNotifyUtils\に書く。SteamVR 登録が exe の絶対パスに依存する性質上、完全なポータブル運用(可搬メディアでの持ち歩き)は対象外である。実装は次の 2 コンポーネントに分割する。
UserNotificationListenerを呼び出し、フラットな C API として公開する C++/WinRT 製の静的ライブラリ(.lib)。ビルド時に本体へリンクされ、単体のファイルとしては配布されない。この分割はプロセスやファイルの分割ではなく、言語境界の分割である。
WinRT は COM ベースの ABI であり、Crystal の C FFI から直接呼ぶには HSTRING 操作や vtable 定義や非同期ハンドラ登録が必要になるため、WinRT 依存を C++/WinRT のライブラリに閉じ込め、Crystal 側は通常の C 関数呼び出しだけで済ませる。
1.1 スコープ
v1 で実装するのは次の範囲である。
加えて、v1 の実装範囲には含まれないが、ソースとシンクの追加を将来アダプタの追加だけで行えるよう、抽象(2.3 節)と設定スキーマ(5 章)は複数ソース複数シンクの形で定義する。
追加候補は 10 章に挙げる。
次の項目はスコープ外とする。
UserNotificationListenerは読み取り専用であるため)1.2 前提環境
UserNotificationListenerの要件。実運用ターゲットは Windows 11)2. システム構成
プロセスも配布ファイルも KxNotifyUtils.exe の 1 つだけである。
シムと libui-ng はリンク時に exe へ統合され、openvr_api.dll だけは同梱せず SteamVR がインストールしたものを実行時に探して使う(4.6 節)。
2.1 コードアーキテクチャの方針
Crystal 本体のコード構成は、参考実装 github_notifications_slack(GitHub 通知を Slack / Discord に中継する Crystal 製ツール)と同じクリーンアーキテクチャ風の構成を採る。
同じ「取得元から通知を取り、中立形式に変換し、差し替え可能な送信先へ送る」形の問題であり、構成をそのまま流用できるためである。
構成の規則は次のとおり。
JSON::Serializable型やドメイン値を置く。lib宣言はコンテキスト内のffi.crに隔離し、同じコンテキストの repository だけが参照する。usecase から上のレイヤは FFI 由来の型を見ない。main.crとする。設定の解決、アダプタの選択、usecase への依存注入をここで一度だけ行う。参考実装がNOTIFY_MODE環境変数で Slack / Discord のアダプタを選ぶbuild_posterを持つのと同型に、本ツールは設定の sinks セクションからbuild_sinksでシンク群を、sources セクションからbuild_sourcesでソース群を組み立てる。参考実装との設計上の違いが 1 つある。
参考実装は送信先だけを抽象化し、取得元(GitHub)は具象のままである。
本ツールは「監視対象と通知先の両方が増える」前提のため、取得元側にも抽象
SourceRepositoryを置いて対称にする(2.3 節)。notify/usecase から見ると、ソースの集合から中立形式の通知を受け取り、シンクの集合へ配る、というハブの形になる。
2.2 ディレクトリ構成
ソースを追加するときは win_notification と並列に新コンテキスト(例:vrchat_log)を作り、
SourceRepositoryを実装してbuild_sourcesに登録する。シンクを追加するときは xsoverlay と並列に新コンテキストを作り、
PostRepositoryを実装してbuild_sinksに登録する。どちらの場合も notify と既存コンテキストの変更は不要、というのが本構成の狙いである。
参考実装との対応は次のとおり。
build_posterによる送信先選択 →main.crのbuild_sinksとbuild_sources(設定の sinks / sources セクションから組み立て)参考実装と異なる点は 3 つある。
本ツールは常駐プロセスなので、実行環境が「Lambda の invocation ループ」ではなく「トレイのメッセージループとポーリングスレッド」になる。
取得元が HTTP API ではなく FFI なので、win_notification と steamvr に
ffi.crの層が増える。そして 2.1 節で述べたとおり、取得元側にも抽象を置く。
2.3 依存規則
依存の向きを次のとおり固定する。
source(ソース識別子。v1 は"windows"のみ)、app_id、app_name、title、body、icon、created_atを持つ。Message はシンクに依存しない送信通知である。Notify::Messageは、どのシンクでも意味を持つコア(title、body、icon、timeout、sound、volume、source_app)と、シンクによっては解釈されない表示ヒント(height、opacity)に分けて定義する。各シンクのアダプタはコアを必ず反映し、表示ヒントは解釈できるものだけを使う(XSOverlay は両方を使う)。v0.2 では「WebSocket と UDP が同じフィールド集合を運ぶ」ことを根拠に単一の論理形としていたが、シンクが増える前提ではその根拠が成り立たないため、この 2 層に再定義する。SourceRepositoryを継承し、Incomingを返す。シンク実装(xsoverlay)の各 repository はPostRepositoryを継承し、Messageを自コンテキストのワイヤ形式(xsoverlay/models)へ変換する。IncomingからNotify::Messageを組み立てる(参考実装で github/usecase がNotify::Messageを組み立てるのと同じ配置)。SourceRepository(抽象)の集合、win_notification/usecase、PostRepository(抽象)の集合、config/models だけとする。XSOverlay にも WinRT にも OpenVR にも依存しない。シンクへの送信は集合の全要素に同報し(fan-out)、あるシンクの送信失敗が他のシンクへの送信を妨げないようにする。この規則の目的はテスト境界の確保と、追加時の変更範囲の限定にある。
PostRepositoryとSourceRepositoryをテスト用実装に差し替えれば、notify/usecase(フィルタ、整形、fan-out の送信順序)を実 XSOverlay にも実通知にも触れず単体テストできる(9.2 節)。またソースやシンクを追加しても、変更が入るのは新コンテキストと
main.crの組み立て、設定スキーマだけになる。3. NotifListenerShim(C++/WinRT シム静的ライブラリ)
3.1 設計方針
NotifListenerShim.libとし、Crystal 本体のリンク時に exe へ統合する。DLL としては配布しない。extern "C"、呼び出し規約は__cdecl)。__declspec(dllexport)は不要で、Crystal 側は@[Link("NotifListenerShim")]のlib宣言で解決する。Crystal 側は標準ライブラリの JSON でパースできるため、FFI 定義がポインタと整数だけで完結する。
IAsyncOperation)はライブラリ内部で同期待ちに変換する(.get())。ポーリング呼び出しは本体側のワーカースレッドから行う想定であり、ブロッキングしても UI には影響しない。
静的リンクにより CRT は本体と同一になるが、確保と解放の責務を API 境界の同じ側に置く対称性は保つ(将来 DLL 形態へ戻す場合にも API を変えずに済む)。
nls_last_errorに変換する。Crystal のランタイムは C++ 例外の巻き戻しを想定していないためである。3.2 公開関数
3.3 通知 JSON スキーマ
nls_get_notificationsは次の形の JSON を返す。{ "notifications": [ { "id": 12345, "app_id": "com.squirrel.Discord.Discord", "app_name": "Discord", "title": "#general | サーバー名", "body": "メッセージ本文", "created_at": "2026-08-24T21:03:05+09:00", "icon_png_base64": "iVBORw0KGgo..." } ] }各フィールドの取得元は次のとおり。
UserNotification.Id。差分検出のキーとして使う。AppInfo.AppUserModelId。フィルタのマッチングキーとして使う。AppInfo.DisplayInfo.DisplayName。Notification.Visualのバインディング(ToastGeneric)からテキスト要素を取得する。1 要素目を title、2 要素目以降を改行連結して body とする。
テキスト要素が 1 つしかない通知では body を空文字列とする。
UserNotification.CreationTime(ISO 8601)。AppInfo.DisplayInfo.GetLogo()から取得したロゴを PNG にデコードして base64 化したもの。取得に失敗した場合はフィールドごと省略する。
アイコンの取得と base64 化は通知ごとに行うとコストが高いため、シムライブラリ内部で
app_idをキーにキャッシュする。キャッシュはプロセス生存期間中保持し、上限は設けない(常駐アプリの実用上、通知を出すアプリ数は高々数十である)。
3.4 スレッドと COM アパートメント
nls_initを呼んだスレッドと同じスレッドから全 API を呼ぶことを契約とする(MTA で初期化する)。本体側はポーリング専用のワーカースレッドを 1 本立て、そのスレッドにシム API の呼び出しを閉じ込める。
4. 本体(Crystal)
本章の各節は 2.2 節のモジュールに対応する。
各節の冒頭に担当モジュールを示す。
4.1 ライフサイクル
担当:
main.cr(起動シーケンスと DI)、runtime/scheduler.cr(ポーリングスレッド)、runtime/tray.cr(メッセージループ)。起動から終了までの流れは次のとおり。
build_sourcesとbuild_sinksでアダプタ群を組み立てる(v1 は Windows 通知ソースと XSOverlay シンク)。Windows 通知ソースの初期化でnls_initを呼ぶ(シムは exe に静的リンク済みのため、ロード処理は無い)。ms-settings:privacy-notifications)への誘導を表示する。許可が得られるまで Windows 通知ソースの中継は行わないが、プロセスは常駐を続ける。VRApplication_Overlayで初期化する。初回起動時は vrmanifest の生成と登録、自動起動設定を行う(4.5 節)。VREvent_Quitを受信した(SteamVR 終了)。WM_QUERYENDSESSIONを受けた(Windows シャットダウン)。SteamVR が起動していない状態で手動起動された場合、OpenVR 初期化は失敗する。
このとき本体は初期化を諦めて常駐を続け、60 秒間隔で OpenVR 初期化を再試行する。
これにより「先にツールを起動し、後から SteamVR を起動する」順序でも終了連動が機能する。
4.2 ポーリングと差分検出
担当:ループの駆動と各ソースの
poll_new呼び出しはnotify/usecase.cr、Windows 通知の差分検出はwin_notification/repository.cr(SourceRepository実装)。v0.3 までは差分検出を notify/usecase の責務としていたが、
id集合の比較は Windows 通知というソースに固有の検出方式であり、ソースが増えたとき(ログの追記読みやイベント購読など検出方式はソースごとに違う)に中立の usecase へソース固有知識が積み上がるため、poll_newの契約(2.3 節)に含めてソース実装側へ移した。usecase は各ソースから受け取った
Incomingを中継パイプライン(4.3 節)に流すだけで、FFI にも検出方式にも触れない。win_notification の
poll_newは次のとおり動く。nls_get_notificationsを呼び、返ってきたidの集合を前回の集合と比較する。idを新規通知とみなし、Incoming(source: "windows")へ変換して返す。起動前から通知センターに溜まっていた通知を一斉送信しないためである。
idは単に集合から除去する(削除された通知の追跡は行わない)。この方式で拾えるのは通知センターに到達した通知だけである。
トーストが画面に表示されない設定(フォーカスアシスト有効時や、アプリ側でバナー非表示のもの)でも通知センターに入れば取得できるが、通知センター自体に入らない通知は取得できない。
この制約は README とセットアップガイドに明記する(7 章)。
4.3 中継パイプライン(フィルタと整形)
担当:手順 1 と手順 7 の送信呼び出しは
notify/usecase.cr、手順 2 のルール解決(defaults との継承マージ)はconfig/models.cr、手順 3 から手順 7 のMessage組み立てはwin_notification/usecase.cr。参考実装で github/usecase の
build_messageが「取得元の通知 + 付随情報 →Notify::Message」の変換を一手に担うのと同様に、整形の知識はソース側の usecase に集約し、notify/usecase はパイプラインの順序制御と fan-out だけを持つ。将来ソースを追加する際も、そのソースの
IncomingからMessageを組み立てる usecase を新コンテキスト内に持たせる(整形ロジックを notify に集めない)。新規通知(
Incoming)1 件ごとに、次の順で処理する。filter.mode(blacklistまたはwhitelist)とfilter.appsに従い、app_idの前方一致で中継可否を決める。rules配列を上から順に評価し、app_idが最初にマッチしたルールを採用する。マッチしない場合はdefaultsを使う。ソースが増えた際は、マッチ条件にmatch_sourceを追加してソース別ルールを書けるようにする(v1 ではソースが 1 つなので実装しない。5 章の注記参照)。"{app_name}: {title}")を適用する。max_body_length(デフォルト 200 文字)を超える場合は切り詰めて末尾に…を付ける。timeout_modeがfixedならtimeout秒をそのまま使う。dynamicなら次式で計算する。文字数は title と body の合計とする。
デフォルトは base 2.0 秒、reading_speed 12 文字/秒、min 3.0 秒、max 15.0 秒とする(日本語の黙読速度の実用値として仮置きし、実測で調整する)。
5. 表示ヒントの決定:
heightは body が空なら 100、あれば文字数に応じて 120 から 250 の範囲で線形に決める(実機で調整する)。opacityはルールから引く。どちらもMessageの表示ヒント(2.3 節)であり、解釈しないシンクは無視してよい。6. アイコンの決定:ルールの
icon設定に従う。"app"(デフォルト):シムが返したicon_png_base64を使う。無ければ"default"にフォールバックする。"default"/"warning"/"error":組み込みアイコンを指すシンボル名としてMessageにそのまま載せる(XSOverlay はこの 3 名を組み込みアイコンとして解釈する。他のシンクを追加する際は各アダプタが自分の同等物へ対応づける)。Notify::Messageを組み立て、全シンクへ渡す(4.4 節)。volumeとsound(audioPathに対応)はルールから引き、soundは"default"/"warning"/"error"/ 音声ファイルパス / 空文字列(ミュート)を許容する。カスタマイズ可能な範囲を
Messageのフィールド(実質は XSOverlay の通知オブジェクトのフィールド集合)に限定していることが、本ツールの設計上の割り切りである。通知の配置やフォントなど、フィールドに存在しない見た目は各シンク側アプリケーションの設定の管轄とする。
4.4 シンクへの送信
担当:抽象は
notify/repository.crのPostRepository、v1 の実装はxsoverlay/websocket_repository.crとxsoverlay/udp_repository.cr。どのシンクを有効にするかは
main.crのbuild_sinksが設定の sinks セクション(5 章)から決め、想定外の値は起動時に例外にする(参考実装のNOTIFY_MODE分岐と同じ扱い。設定ミスのまま黙って動かさない)。notify/usecase は
PostRepositoryの集合だけを知り、Messageを全シンクへ同報する。あるシンクが送信に失敗しても他のシンクへの送信は続け、失敗はシンクごとにログへ残す。接続と再接続とワイヤ形式変換はアダプタに閉じる。
XSOverlay アダプタの仕様は次のとおり。
接続先は
ws://localhost:{port}/?client=KxNotifyUtilsで、port のデフォルトは 42070 とする。client クエリパラメータが無いと XSOverlay 側が接続を拒否するため、必ず付与する。
Messageを XSOverlay の通知オブジェクトへ変換し、エンベロープに包んで送る。{ "sender": "KxNotifyUtils", "target": "xsoverlay", "command": "SendNotification", "jsonData": "{\"type\":1,\"title\":\"...\",\"content\":\"...\",\"timeout\":6.5,\"height\":175.0,\"opacity\":1.0,\"volume\":0.7,\"audioPath\":\"default\",\"useBase64Icon\":true,\"icon\":\"...\",\"sourceApp\":\"KxNotifyUtils\"}", "rawData": null }変換は素直なフィールド対応(
body→content、sound→audioPath、base64 アイコンならuseBase64Icon: true)で、表示ヒントのheightとopacityもそのまま載せる。HTTP::WebSocketを使う(外部 shard 不要)。XSOverlay が動いていない状況とは VR 外である可能性が高く、そのとき通知は通常のトーストで見えているため、後からまとめて HMD に流す価値がない。
transportを"udp"にした場合はレガシー UDP API(127.0.0.1:42069、デフォルト)に同じ通知オブジェクトを直接送る。WebSocket に問題が出た場合の退避手段としてのみ残し、ドキュメント上は非推奨とする。
4.5 SteamVR スタートアップ登録
担当:
steamvr/usecase.cr(vrmanifest の生成、登録判断、パス変更検知)とsteamvr/repository.cr(OpenVR 呼び出し)。vrmanifest は配布物に含めず、実行時に生成する。
起動時に自分の exe の絶対パスを
binary_path_windowsに書き込んだ次の内容を%APPDATA%\KxNotifyUtils\kxnotifyutils.vrmanifestへ書き出す。{ "source": "builtin", "applications": [{ "app_key": "kairo.kxnotifyutils", "launch_type": "binary", "binary_path_windows": "<起動時に解決した exe の絶対パス>", "is_dashboard_overlay": true, "strings": { "en_us": { "name": "KxNotifyUtils", "description": "Relays notifications to XSOverlay." }, "ja_jp": { "name": "KxNotifyUtils", "description": "通知を XSOverlay に中継します。" } } }] }生成先を exe の隣ではなく
%APPDATA%に固定するのは、SteamVR に渡すマニフェストのパスを exe の置き場所から独立させるためである。exe を移動しても SteamVR が参照するマニフェストパスは変わらず、マニフェストの中身(
binary_path_windows)を書き換えるだけで追従できる。登録の手順は次のとおり。
IVRApplications::AddApplicationManifest(生成した vrmanifest の絶対パスを渡す)IVRApplications::SetApplicationAutoLaunch("kairo.kxnotifyutils", true)last_exe_pathに記録)と現在の exe パスを比較する。異なる場合は exe が移動されたとみなし、vrmanifest を再生成してAddApplicationManifestを再実行する(SteamVR 側のキャッシュを確実に更新するため、マニフェスト内容の書き換えだけで済ませず再登録まで行う)。SetApplicationAutoLaunch(..., false)とRemoveApplicationManifestで行い、トレイメニューから実行できるようにする。解除時に生成した vrmanifest ファイルも削除する。4.6 OpenVR FFI(Crystal 側最小バインディング)
担当:
steamvr/ffi.cr。関数ポインタ型と FnTable の構造体定義をここに閉じ、参照するのはsteamvr/repository.crだけとする。openvr_api.dll は同梱せず、SteamVR がインストールしたものを実行時に探して動的ロードする。
openvr_api.dll は実体である SteamVR ランタイムへの薄いローダーであり、SteamVR と同時に配布・更新されるものを使えば、同梱版の陳腐化(古い openvr_api を配り続ける状態)を避けられる。
解決の手順は次のとおり。
%LOCALAPPDATA%\openvr\openvrpaths.vrpathを読む。これは OpenVR 標準のパス設定ファイル(JSON)で、runtime配列に SteamVR ランタイムのインストール先が入っている。runtimeの各エントリに対し<runtime>\bin\win64\openvr_api.dllの存在を確認し、最初に見つかったものをLoadLibraryWでロードする。GetProcAddressで解決する。静的リンクやインポートライブラリは使わないため、Crystal 側のlib宣言は関数ポインタ型の定義だけになる。GetProcAddressで解決する関数は次のとおり。VR_InitInternal2/VR_ShutdownInternalVR_IsInterfaceVersionValidVR_GetGenericInterface("FnTable:IVRApplications_007"と"FnTable:IVRSystem_022"の取得に使う)IVRApplicationsのAddApplicationManifest/RemoveApplicationManifest/SetApplicationAutoLaunch/GetApplicationAutoLaunch/IsApplicationInstalled(FnTable 経由)IVRSystem::PollNextEvent(VREvent_Quitの検知用。FnTable 経由)インターフェースバージョン文字列はビルド時に参照する openvr ヘッダの版に合わせて確定し、起動時に
VR_IsInterfaceVersionValidで検証する。SteamVR 側の openvr_api.dll は後方互換に保たれているため、本ツールが要求する版が古い分には問題にならない。検証に失敗した場合(想定外に古い SteamVR など)は SteamVR 連携を無効にしてログに残す。
VREvent_Quitを受信したらIVRSystem::AcknowledgeQuit_Exitingを呼んでから終了処理に入る。4.7 トレイ UI
担当:
runtime/tray.cr。メニュー項目のハンドラは対応する usecase(中継の一時停止は notify、登録と解除は steamvr、再読み込みは config)を呼ぶだけで、tray 自身はロジックを持たない。GUI ツールキットを使わず、Win32 API 直叩き(
Shell_NotifyIconとメッセージウィンドウ)で実装する。表示するメニューは次の項目に限定する。
4.8 設定編集 GUI
担当:
runtime/settings_window.cr(画面)とconfig/usecase.cr(検証、保存、反映)。2.3 節の規則どおり、settings_window は入力値の受け渡しと表示だけを行い、検証と保存と反映のロジックは config/usecase に置く。
GUI ツールキットには uing(libui-ng の Crystal バインディング)を使う。
トレイ(Win32 直叩き)と混在するが、libui-ng のメインループも同一スレッドの Win32 メッセージポンプであるため、UI スレッドを 1 本に統一すればトレイのメッセージウィンドウと共存できる見込みである。
この共存は実装初期にプロトタイプで検証し、成立しない場合は設定ウィンドウを専用 UI スレッドに分離する(未決事項)。
ワーカースレッド(ポーリング、OpenVR イベント)から UI への操作は
uiQueueMain相当の仕組みで UI スレッドに転送する。4.8.1 画面構成
シングルトンウィンドウとし、トレイメニューの「設定」で開く(既に開いていれば前面化する)。
タブで次の 7 画面に分ける。
監視対象タブと通知先タブは設定スキーマの sources / sinks セクション(5 章)にそのまま対応し、将来のソースやシンクはこの 2 タブ内の項目として増える。
match_app_idを表示)、右に選択中ルールの編集フォームを置く。フォームは defaults と同じ項目に「この項目を上書きする」チェックを付けた形とし、チェックが外れた項目は defaults 継承(JSON 上はフィールド省略)になる。ルールの追加、削除、並べ替え(先勝ちマッチのため順序に意味がある)を提供する。match_app_idの入力補助として、直近のポーリングで観測したapp_idとapp_nameの一覧から選択できるようにする。フィルタ(mode と apps の一覧編集)もこのタブに含め、同じ選択補助を付ける。各タブ共通で、ウィンドウ下部に「テスト通知を送信」「保存」「閉じる」を置く(情報タブは表示のみで、保存対象の項目を持たない)。
テスト通知は現在編集中(未保存)の defaults を使って有効な全シンクへ送信し、設定を保存せずに見た目と音を確認できるようにする。
4.8.2 保存と反映
soundのファイル不存在、match_app_idの空文字列など)は該当タブを示してダイアログで表示する。保存は全項目が有効なときだけ行う。Config値)の差し替えで行い、ポーリング周期の途中で新旧設定が混ざらないようにする。5. 設定ファイル仕様
配置場所は
%APPDATA%\KxNotifyUtils\config.jsonとする。最上位を sources(監視対象ごとの設定)、sinks(通知先ごとの設定)、それらに横断的な filter / defaults / rules / steamvr に分ける。
sources と sinks はソース識別子とシンク識別子をキーとするオブジェクトで、ソースやシンクを追加したときは新しいキーを足すだけで済み、既存キーの互換が保たれる。
{ "sources": { "windows": { "enabled": true, "polling_interval_ms": 500 } }, "sinks": { "xsoverlay": { "enabled": true, "transport": "websocket", "websocket_port": 42070, "udp_port": 42069 } }, "filter": { "mode": "blacklist", "apps": ["Microsoft.Windows.Explorer"] }, "defaults": { "timeout_mode": "dynamic", "timeout": 6.0, "dynamic_timeout": { "base": 2.0, "reading_speed": 12, "min": 3.0, "max": 15.0 }, "max_body_length": 200, "title_template": "{app_name}: {title}", "icon": "app", "opacity": 1.0, "volume": 0.5, "sound": "default" }, "rules": [ { "match_app_id": "com.squirrel.Discord", "volume": 0.8, "sound": "C:/sounds/discord.wav" }, { "match_app_id": "Microsoft.OutlookForWindows", "timeout_mode": "fixed", "timeout": 10.0, "icon": "warning", "volume": 0.0 } ], "steamvr": { "auto_launch_registered": true, "last_exe_path": "D:/tools/KxNotifyUtils.exe" }, "log_level": "info" }rulesの各項目はdefaultsとの差分だけを書けばよい(未指定フィールドはdefaultsを継承する)。rulesのマッチ条件は v1 ではmatch_app_idのみとする。ソース追加時にmatch_sourceを追加してソース別ルールを書けるようにする予定であり、match_プレフィックスはその拡張のために予約する。enabled: false)の設定は検証エラーとする。中継先が無い常駐は設定ミスである可能性が高いためである。6. ログ
%APPDATA%\KxNotifyUtils\logs\とし、日次ローテーションで 7 世代保持する。infoでは起動と終了、各シンクの接続と切断、登録操作、中継件数(ソース別とシンク別の 1 分ごとの集計)を記録する。debugでは中継した通知のsourceとapp_idと title を記録する。body は記録しない(通知本文は個人情報を含みやすいため、デバッグレベルでも残さない)。7. 既知の制約(ドキュメント記載必須事項)
利用者向けドキュメントに次を明記する。
Windows 設定 > プライバシーとセキュリティ > 通知 で本アプリに通知へのアクセスを許可する必要がある。
パッケージ化されていないアプリではアプリ内からの許可要求が失敗することがあり、その場合は設定画面から手動で許可する。
フォーカスアシスト(応答不可モード)や、アプリ側の通知バナー設定によって抑制された通知は取得できないことがある。
VR ゲームがフルスクリーン最適化で通知を抑制するケースが典型であり、確実に中継したいアプリはフォーカスアシストの優先リストに入れることを推奨する。
別マシンの通知を中継する構成は組めない。
本ツールが制御できるのはアイコン、表示時間、高さ、透明度、通知音、テキスト内容である。
exe を移動すると次回起動時に vrmanifest を再生成して再登録する(4.5 節)。SteamVR 起動中に移動した場合、SteamVR 側が新しいパスを認識するのは再登録後になるため、自動起動が一度失敗することがある。
8. ビルドと配布
配布物は KxNotifyUtils.exe の 1 ファイルである(1 章)。
これを成立させるためのリンク構成を本章で定める。
8.1 ビルドとリンク構成
/std:c++20)。成果物はNotifListenerShim.lib。/MT)で統一できるかをビルド整備の最初に検証し、リンク衝突(defaultlib の混在)が解消できない場合は動的 CRT(/MD)で統一する。/MDの場合、Windows 10 以降は UCRT が OS 同梱のため配布物は増えないが、vcruntime への依存が残る可能性を README に記載する。crystal build --releaseし、--link-flagsでNotifListenerShim.lib、libui-ng の .lib、および必要な Windows システムライブラリ(windowsapp.libなど C++/WinRT が要求するもの)を渡す。openvr は実行時ロード(4.6 節)のためリンク対象に含めない。crystal spec(9.2 節の単体テスト)を通してから exe 単体を成果物にする。単体テストは外部依存を持たない(2.3 節)ため、ubuntu ランナーでも実行できるが、FFI 宣言のコンパイル確認を兼ねて windows ランナーで実行する。
8.2 配布物の構成
配布は exe 1 ファイルのみとし、インストーラも zip も作らない。
README や LICENSE はリポジトリと GitHub Release の本文で提供し、サードパーティライセンス表記は exe に埋め込んだ THIRD-PARTY-NOTICES(8.1 節)を設定 GUI の情報タブで表示するものを一次とする。openvr ヘッダ由来のコードを含むため BSD-3 の表記もそこに含め、Release 本文と exe のバージョン情報にも併記する。
設置先は任意で、exe を移動しても SteamVR 登録は次回起動時に追従する(4.5 節)。
9. テスト計画
9.1 シム静的ライブラリ単体
シムは exe に統合されるため、テストは C API を呼ぶ小さなテストハーネス exe(C++ 側のプロジェクトに同居させ、
NotifListenerShim.libをリンクしたもの)で行う。配布物ではない。nls_get_access_statusが正しい値を返すこと。nls_get_notificationsを 10,000 回連続で呼んでメモリリークがないこと(アイコンキャッシュ分の増加を除く)。nls_last_errorに変換されること(3.1 節)。9.2 Crystal usecase 単体(crystal spec)
2.3 節のテスト境界を使い、外部依存なしで検証する。
SourceRepositoryとPostRepositoryはテスト用実装(返すIncomingを差し込め、送信されたMessageを記録するもの)に差し替える。差分検出は win_notification 側へ移した(4.2 節)ため、そのテストはシム API の呼び出し部分だけをテスト用に差し替えた
SourceRepository実装に対して行う。poll_newの結果がすべてパイプラインに乗ること。フィルタの blacklist / whitelist がapp_id前方一致で効くこと。複数シンクへの fan-out で、1 つのシンクの送信失敗(send_messageが false)が他のシンクへの送信を妨げないこと。失敗した通知はキューされず破棄されること。poll_newが空を返すこと。2 回目以降で新規idの通知だけがIncomingとして返ること。消えたidが集合から除去されること。match_app_id、全シンク無効)が仕様どおりに弾くこと。保存でスナップショットが差し替わり、旧スナップショットを参照中の処理に影響しないこと。9.3 結合
9.4 SteamVR 連動
VREvent_Quit経路と、SteamVR 側の起動阻害が起きないこと)。10. 将来拡張の方針と候補
ソースとシンクの追加手順は 2.2 節末尾のとおり(新コンテキスト +
main.crの登録 + 設定キーの追加)であり、本章はその受け皿で何を追加しうるかの候補を挙げる。いずれも本仕様の実装対象ではなく、着手時に個別の仕様を起こす。
ソースの候補:
VRChat ログイベント:VRChat のログファイルを追記読みし、ワールド移動、フレンドの join / leave、招待などを通知にする。既存ツール XSOverlay-VRChat-Parser と同領域だが、本ツールに載せれば defaults / rules による整形と将来の複数シンクを共有できる。
外部サービスの障害検知:VRChat、YouTube、Steam、Discord の障害情報を通知にする。各サービスの公開ステータスページ(VRChat と Discord は Statuspage 形式の JSON API を公開している。Steam と YouTube は公式のプログラム向けステータス提供が限定的なため、着手時に取得元を選定する)を一定間隔でポーリングし、稼働状態が正常から変化したときだけ
Incomingを発行する。VR プレイ中に「VRChat 側の障害なのか自環境の問題なのか」を切り分けられるのが主目的で、VRChat 単体では既存ツール XSOverlay-VRChat-Status と同領域になる。状態変化の検出(前回状態との比較)はソース実装に閉じる設計(2.3 節のpoll_newの契約)にそのまま乗る。スマートフォン通知:別デバイスからの受信はネットワーク受け口が必要になり、本体をサーバー化する設計変更を伴うため、候補ではあるが優先度は低い。
シンクの候補:
Discord webhook:VR 外や離席中にも記録が残る通知先。
Messageのコアだけで表現でき、表示ヒントは無視される(2.3 節の 2 層定義が最初に効く例になる)。OSC(VRChat チャットボックスなど):短文のコアのみを流す縮退表示。
他のオーバーレイ通知 API:OVR Toolkit 等、対応 API を持つオーバーレイが対象になりうる。
拡張時に再検討が必要になる既知の論点も残しておく。
filter と rules は現状
app_id前提であり、ソースが増えた時点でmatch_sourceの追加(5 章の予約)と、ソース別の観測補助(GUI の選択リスト)の拡張が必要になる。またシンクが増えた場合、ルールで「この通知はこのシンクだけへ送る」というルーティングの要望が出ることが予想されるが、設定の複雑さと引き換えになるため、必要になった時点で rules への
sinksフィールド追加として設計する。11. 未決事項
/MTで統一できるか(8.1 節)。Crystal の標準リンク構成との衝突が解消できない場合は/MD統一へ切り替える。ビルド整備の最初に検証する。runtime配列の内容は環境(SteamVR の複数バージョン併存など)で差がありうるため、実環境のファイルを確認して 4.6 節の解決手順のフォールバック(Steam のレジストリからのsteamapps\common\SteamVR解決を足すか)を判断する。GetLogo()が返すロゴの形式はアプリによって差があり、透過や余白の見え方は XSOverlay 実機で確認して調整する。参考資料