Twitch 放送開始通知アプリを tauri(rust) + ngrok で作る 第 1 回 : ベースの部分

第 2 回はきっとある

環境

Windows 10 Pro 23H2 64bit
WSL 2 (Ubuntu 20.04)
rustc 1.71.0
tauri 1.4.1

概要

Twitch の配信開始を通知するネイティブアプリケーションを作り始めました。
単純に rust を学びたかったのと、やる気減退期をやっと脱しそうだったので久々に趣味でアプリ書きたかったというのが簡単な動機です。

electron のデフォルト UI を流用している

以前作ってたなんとか生放送の通知アプリみたいな感じ。
予め配信開始の監視先リストに目標となる配信者を追加しておいて、後は勝手に配信開始を待機して通知するものです。
やっとベースとなる機能の実装が一段落したので一旦まとめます。

  • tauri とは何かみたいな解説はしないつもりです
  • もうちょっとリファクタリングしてからリポジトリ公開したい
  • 当初 electron で作成していたのを諸般の事情から tauri に移植しています

環境作成

tauri-app のインストール

React + TypeScript を選択しています。

$ npm create tauri-app@latest
$ npm install

npm run tauri dev するまで

WSLg を使えるならそれがいいと思うのですが、自分の環境ではどうにも使えなかったので、明確に無効にしてあります。

// %userprofile%\.wslconfig
[wsl2]
memory=4GB
swap=0
guiApplications=false

VcXsrv をインストールし、起動時に設定を行います。

  • Extra settings > Disable access control にチェック

エラー対応等のため、以下を ~/.bashrc に追加します。

# (WebKitWebProcess:13130): Gdk-ERROR **: 01:37:54.674: The program 'WebKitWebProcess' received an X Window System error.
export DISPLAY=$(ip route | grep 'default via' | grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}'):0
export WEBKIT_DISABLE_COMPOSITING_MODE=1

これで npm run tauri dev を実行すればビルドされて GUI が表示するはずですが、
もうちょっと何かしら設定を行う必要があったかもしれない

twitch の API まわりの仕様

アクセストーク

放送開始の通知を受けるためにはクライアント ID とアクセストークンが必要になります。
クライアント ID は Twitch Developers のコンソールから開発中のアプリを登録することで取得できます。

アクセストークンには user access token と app access token の 2 種類あります。
それぞれ取得の方法も利用の仕方も異なりますが、大体は以下の公式 docs に書いてあります。
Authentication | Twitch Developers

Twitch の API はエンドポイントに応じてどの種類のアクセストークンが必要かが変わりまして、
大雑把に言えば user access token は特定の Twitch ユーザを認証してトークンを得るため、"投票の開始"や"ユーザの BAN"といった Twitch 上での操作を行うのに必要で、
app access token はアプリ自体の認証だけでいいため、情報の取得全般に向いています。

EventSub

API に対して HTTP リクエストを飛ばすだけならもちろん Pull 型ですが、
放送開始の通知は Push 型となるため、アプリ側で Twitch からの通信を待ち受ける仕組みが必要です。
Twitch では WebSocket と Webhook の 2 つが用意されています。
基本的に、WebSocket の場合は user access token が、Webhook の場合は app access token が必要になります。

しかし、上記の通信路を用意したところで誰が放送開始しようが通知は飛んできません。
「誰が配信したら通知してくれ」と Twitch にお願いする必要があります。
上記の配信開始や配信終了、raid やチャンネルフォロー等のイベントを Twitch では EventSub と呼称します。
通知を飛ばすようにお願いするため、EventSub を購読(Subscription)しなければいけません。

ここで問題となるのが EventSub の購読コストです。
どの EventSub も購読時にコストというものが支払われまして、あまりに大量の EventSub を購読するとサーバに負荷がかかる等の諸問題が発生するためか、
支払えるコストには限界があります。

さて、簡単に言えば WebSocket(user access token) では放送開始の EventSub のコストが 10 までしか払えません。
放送開始の対象とするユーザがアプリを認証してくれればコストが 0 になるようですが、それは非現実的です。
逆に Webhook(app access token) ではコストが 10000 も払えるため、放送終了 EventSub も合わせれば 5000 件のユーザを監視できることになります。

最初は実装が楽なことから WebSocket の方を進めていましたが、
上記のコスト事情から Webhook を選択せざるを得なくなりました。

Webhook の HMAC 検証

セキュリティのため、Webhook によるリクエストに twitch-eventsub-message-signature ヘッダが付与されており、内容は HMAC-SHA256 となっています。
堅牢なアプリを目指すため、リクエストを受け取るたびに内容が正しいかどうか検証する必要があります。
正直なところ公式 docs の js の記述を rust で書き直すだけで大丈夫です。
Handling Webhook Events | Twitch Developers

HMAC 鍵は指示されている通り、ascii 文字を 64 文字とし、EventSub の購読時に API を通して送信します。
これは検証の時に必要なのでアプリで持っておくとし、基本的にランダム生成としています。

暗号化するメッセージですが、twitch-eventsub-message-id ヘッダと twitch-eventsub-message-timestamp ヘッダと raw の body を連結したものになります。
これは普通に文字列連結で構いません。

そしてダイジェストを得る際のエンコーダですが、公式 docs で .digest('hex') をコールしているのに気づかずしばらく時間がかかってしまいました。
rust なら data_encoding::HEXLOWER で構いません。

まだ汚いのであまり晒したくはないのですが以下のような感じです。

use data_encoding::HEXLOWER;
use hmac::{Hmac, Mac};
use sha2::Sha256;

// id + timestamp + 生の body の順に連結し、HMAC-SHA256 ダイジェスト
を作成する
let message = header.get("twitch-eventsub-message-id").unwrap().to_str().
unwrap().to_string()
    + header.get("twitch-eventsub-message-timestamp").unwrap().to_str().unwrap()
    + &body_raw;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(&secret.as_bytes()).unwrap();
mac.update(&message.as_bytes());
let digest = HEXLOWER.encode(&mac.finalize().into_bytes());

ちなみに、もしダイジェストが twitch-eventsub-message-signature から得られるものと異なる場合、4xx エラーを返すように指示されています。

Twitch CLI

開発用に EventSub を任意のタイミングで発火するためのツールが用意されていました。
ubuntu であれば基本は homebrew 経由のみでしかインストールできません。

アプリの secret は Twitch Developers のコンソール上から取得します。

# インストールスクリプトの実行
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 以下の指示に従う
==> Next steps:
- Run these two commands in your terminal to add Homebrew to your PATH:
    (echo; echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"') >> /home/{ユーザ名}/.bashrc
    eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"

# Twitch CLI のインストール
$ brew install twitchdev/twitch/twitch-cli

# アプリの id/secret を設定する
$ twitch configure

ここまで設定すれば EventSub の発行等のテストが行えます。
実際に配信が始まりそうな人を登録して notification イベント待つとかやってたらめちゃくちゃ開発効率悪いので、絶対に当該ツールの使用をオススメします。

# HMAC challenge のテスト
$ twitch event verify-subscription streamup -F {コールバック URL} -s {HMAC の secret}

# 放送開始 EventSub の発火テスト
$ twitch event trigger streamup -F {コールバック URL} -s {HMAC の secret} --to-user {ユーザ ID}

テスト項目の確認はみんな大好き公式 docs で!
Test webhook events | Twitch Developers

Webhook を受けるために ngrok を使う

ngrok はローカルのネットワークサービスを外部公開するためのサービスです。
2023 年の 7 月前後に free 版でも静的サブドメインが取得できるようになっており、かなり開発が楽になりました。

ngrok のトンネルを構築し、axum router をグリーンスレッドで稼働することで外部からの Webhook 通信を受けるようにしています。
(エラーハンドリングをまだしてないので書き直します)
(全然セキュアじゃないのでいろいろ考えます)

// Cargo.toml
ngrok = { version = "0.13.1", features= ["axum"] }

// トンネル構築処理
use ngrok::{config::TunnelBuilder, tunnel::UrlTunnel};

let session = ngrok::Session::builder(
    .authtoken(ngrok_authtoken)
    .connect()
    .await;
if let Err(e) = session {
    println!("{:?}", e.to_string());
    return Err(format!("session creating: {}", e.to_string()));
};
let tunnel_wrapped = session.unwrap()
    .http_endpoint()
    .domain(ngrok_domain)
    .listen()
    .await;
if let Err(e) = tunnel_wrapped {
    println!("{:?}", e.to_string());
    return Err(format!("tunnel listening: {}", e.to_string()));
}

少しでも隠蔽できるようにコールバック URL は毎回ランダムなパスにしています。

React の router 周り

tauri や electron で react-router を使用する時、
基本的には 1 画面上で操作を行う UI となるため、router としては HashRouter を選択するのが丸いです。
/#{path} に対して GET リクエストをするようになります。
全てのリクエストが一旦 / に飛び、パスはハッシュで扱います。
createHashRouter v6.20.1 | React Router

createHashRouter() に渡す配列はそれぞれリクエストした #{path} に応じて呼び出したい element を設定していくことになります。
上記 URL では親(/) に対し、子(#team)を設定しています。
つまり、/#team に GET リクエストをした際、まず Root モジュールが取得され、子モジュールとして Team が取得される流れになります。

今回のアプリでは左側にメニューを設置し、選択した内容に応じて右側の内容を変化させていますが、そういった場合に役立つのが Outlet モジュールです。
現在のリクエスト先パスから判断し子モジュールとして取得されたものが Outlet モジュールとして利用できるイメージです。
私のアプリとは内容が若干異なりますが、以下のようにすれば固定で表示したい左側メニュー(Navigation)と、パスに応じて取得先を変えたい右側(Outlet)が定義できます。

const Root= () => {
  return (
    <div className='outer'>
      <Navigation />
      <div className='container'>
        <Outlet />
      </div>
    </div>
  )
}

github actions で各プラットフォーム用にビルド

これ本当に最高でたまらん

  • tauri.conf.json の identifier を任意の被らないものにします
  • githubリポジトリ設定を変更
    • Settings > Actions > General > Workflow permissions > Read and write permissions
  • workflow を作成: .github/workflows/build.yaml
    • https://github.com/tauri-apps/tauri-action の Uploading the artifacts to a release を参照
    • 以下のようにすれば手動で実行できる: github の Actions から Run Workflow を手動実行すればいい
      • on: workflow_dispatch

なんと簡単な設定を最初にしておくだけで win, macos, ubuntu 向けのアプリを任意のタイミングでビルドしてくれます。
私は手動にしました。

todo

  • ngrok まわりをもっとセキュアに
  • 通知を小さなウィンドウとかでやりたい
    • システムの通知はリンク先をブラウザで開くというアクションができない
  • エラーハンドリングちゃんとやれ
    • ログがアプリ上からも閲覧できるような機構を作りたい
    • そもそもロギングしてない
  • エントリポイント(main.rs)がごちゃごちゃしているのはどの言語でも良くないので rust でもきっと良くない
  • YouTube にも対応したい
    • Websub なら放送開始が拾えるかもしれないらしいが、これだけの有名サービスで誰も実装してる記事を書いてないのは不可能であることの裏付けなのかもしれない

雑多

C# ぶりに自分にかなり上手くはまった言語でした。
実務でも触りたいくらいには本当に好きになってきている。
(遍歴: C# -> C/C++ -> PHP, js(vue) -> rust, js(react))

linter がとにかく丁寧に指示してくれるので書きにくさはそこまで感じないです。
所有権/ライフタイムあたりの概念が学習を難しくさせているとは思いますが、個人的には技術基盤のない初心者ほど手を付けてほしい言語です。
なんでこれが流行んねえんだろうなぁと思ってます。
(なんなら先入観のせいで私も手付けるの遅かったし)

オンでもオフでもモンハンに追われてる毎日なのでここからまた時間はかかるかと思いますが、
一般公開できるくらいには改修していきたいです。
rust おもろい。

Servo

これ来たら tauri が本当に最強になってしまうので期待してます。
Rust製ブラウザエンジンの「Servo」、アプリに組み込み可能なクロスプラットフォーム対応WebView化を目指す。Electron代替を目指す「Tauri」への組み込み実現へ - Publickey

こっちは受け入れ側の tauri-apps。
毎日のようにネットワークが動いているのが本当に素敵
Commits · tauri-apps/wry · GitHub