Sync and State
useOverlayStore の状態同期は 複数経路 を Adapter パターンで抽象化し、CompositeAdapter で 1 つに合成している。本ページは Adapter 構成 / 受信ループ防止 / 正本(overlay-config.json)との関係 / store の分割をまとめる。
ストア構造
src/store/
├── useOverlayStore.ts # create() で state / actions / sync を合成(薄いエントリ)
├── index.ts # 公開 API + initStoreSync / getActiveSyncAdapter
├── overlay/
│ ├── state.ts # OverlayStore 型 + 初期値 + 派生関数
│ ├── actions.ts # createOverlayActions(set/get を受ける)
│ ├── sync.ts # createSyncBridge(broadcastSet / receive guard)
│ └── legacyOverlayStorage.ts # 旧 localStorage(overlay-storage)からの 1 回だけの移行
└── sync/
├── adapter.ts # SyncAdapter interface + NoopAdapter
├── electronAdapter.ts
├── broadcastAdapter.ts
├── wsAdapter.ts
├── localStorageAdapter.ts
└── compositeAdapter.ts # 複数 adapter を 1 つに合成Adapter インターフェース
// src/store/sync/adapter.ts
export interface SyncEnvelope {
payload: Partial<OverlayState>; // 画面→正本はパッチ、正本→画面はスナップショット or パッチ
sourceId?: string;
timestamp?: number; // ログ用。新旧の判定には使わない
kind?: 'patch' | 'seed'; // seed = 旧 localStorage からの移行
revision?: number; // 正本が受けた順の連番
snapshot?: boolean; // true なら全キー
bootId?: string; // 正本を持つプロセスの起動ごとの ID(旧版の所有者からは無い)
}
export interface SyncAdapter {
start?: () => void;
stop?: () => void;
send: (envelope: SyncEnvelope) => void;
onReceive: (fn: (envelope: SyncEnvelope) => void) => void;
}send(envelope): 状態の差分を相手側へ送るonReceive(fn): 受信時にfnを呼ぶよう登録(複数登録可)start/stop: 接続管理(WS や IPC のリスナ登録 / 解除)
NoopAdapter は同期不要な環境向けの no-op 実装。
Adapter ごとの役割
| Adapter | 用途 | トランスポート |
|---|---|---|
ElectronAdapter | Electron control / overlay window 間の同期 | window.electronAPI.syncConfig / onConfigUpdated(IPC) |
BroadcastAdapter | 同一ブラウザ・同一 origin 内の複数タブ | BroadcastChannel |
WSAdapter | OBS ブラウザソース ↔ 開発サーバの相互通信 | WebSocket |
LocalStorageAdapter | OBS local file mode 等のフォールバック | localStorage + storage イベント |
CompositeAdapter
複数 adapter を 1 つの SyncAdapter として扱う ためのラッパ。
// 抜粋: src/store/sync/compositeAdapter.ts
export class CompositeAdapter implements SyncAdapter {
send(envelope: SyncEnvelope) {
this.adapters.forEach(a => {
try { a.send(envelope); }
catch (e) { console.warn('[CompositeAdapter] send failed', e); }
});
}
onReceive(fn: (e: SyncEnvelope) => void) {
this.adapters.forEach(a => a.onReceive(fn));
}
}send: 全 adapter に fan-out(1 つ失敗しても他は続行)onReceive: 同じコールバックを全 adapter に登録。どの経路で来てもsetStateが呼ばれるstart/stop: 全 adapter に伝播
環境ごとの合成例
initStoreSync() が現在の window の能力を判定して必要な adapter を組み立てる。renderer entry (main.tsx / overlay-main.tsx) で 1 度だけ呼べばよく、idempotent。
送受信フローと無限ループ防止
isReceiving フラグ
受信した変更を setState でストアに反映すると、subscribe している listener から 再度 broadcastSet が呼ばれる 危険がある。これを防ぐため overlay/sync.ts 内に モジュールスコープの isReceivingRef を持ち、受信処理中は broadcastSet 経由の send を skip する。
// 抜粋: overlay/sync.ts
const isReceivingRef = { current: false };
function broadcastSet(diff: Partial<OverlayState>) {
if (isReceivingRef.current) return; // 受信中は送らない
adapter.send({ payload: diff, sourceId: CLIENT_ID });
}
function onReceiveEnvelope(env: SyncEnvelope) {
isReceivingRef.current = true;
try { useOverlayStore.setState(env.payload); }
finally { isReceivingRef.current = false; }
}CLIENT_ID の役割
各 renderer プロセスで 一時的な ID(crypto.randomUUID() ベース)を持ち、SyncEnvelope.sourceId に詰める。これで自分が送ったメッセージが折り返されたかどうかを判別できる。
永続化 ID にしないのは、Electron の control / overlay window が同一 origin の
localStorageを共有してしまい、両ウィンドウが同じ ID になって受信を自己送信扱いで捨ててしまうため。
新旧の決め方(timestamp は使わない)
新旧は 正本が受けた順 で決まる。正本は 1 プロセスだけなので順番は一意で、timestamp の比較はしない。 renderer が捨てるのは「適用済み revision より古いスナップショット」(起動直後の get-config の応答が push と行き違った場合)だけ。
ただし revision は所有者プロセスの中でしか比べられない。所有者が再起動すると revision はファイルに残っていた値からやり直す (落ちる直前の変更が保存されていなければ、画面が覚えている revision より小さくなる)。そのため所有者は起動ごとの bootId を 全スナップショット / パッチに付け、renderer(src/store/overlay/sync.ts)は bootId が前回と違えば覚えている revision を 0 に戻してから判定する (=別の起動のスナップショットは revision に関係なく受け入れる)。bootId の無い envelope(旧版の所有者)は従来どおり revision だけで判定する。 bootId は WS の UPDATE フレーム、config-updated / get-config、OverlayConfigRelay の中継のすべてで運ばれる。 また、前回のスナップショット以降にその画面が自分で送ったキーは、スナップショットで上書きしない(送った値はいずれ正本に入る)。
正本と保存(spec: overlay-config-single-source)
オーバーレイ設定(テキスト・キャラ・レイアウト・勝敗)の 正本は配信サーバの所有者の <userData>/overlay-config.json ただ 1 つ。 所有者は DB writer と同じプロセスで、委譲中は Recorder、own-writer の Manager は Manager main(OverlayConfigOwner)。 管理画面 / overlay 窓 / OBS ページは正本を持たず、localStorage にも保存しない。
- 画面 → 正本:
broadcastSetは 変わったキーだけ(パッチ) を送る。全量を送ると、別のキーを編集中の画面の値まで巻き戻すため - 正本 → 画面: 接続直後(WS)/ 起動時(
get-config)に全量(snapshot: true)、以降は受けたパッチをrevision付きで送信元以外へ配る - 委譲判定の前: Manager main の
SwitchableOverlayConfigChannelが管理画面の変更を溜め、正本か中継が決まったら流し込む - Manager⇔Recorder は操作用ポート(47831): 委譲 Manager の
OverlayConfigRelayはLiveEventWsServerの/overlay-syncに繋ぐ。 OBS 用ポート(47832)は OBS ページ専用で、OBS 配信を OFF にしたりポートを変えたりしても Manager の編集は届く(OverlaySyncEndpointを両方に載せている) - Recorder 未接続の間:
OverlayConfigRelayがパッチをキー単位でまとめて持ち、再接続時に送る - 終了時:
will-quitで正本をdispose()し、未書き込み分を書く。以後プロセスが消えるまでに届く変更も都度書く - 勝敗は即時保存: 保存はふだん 500ms の debounce だが、
wins/losses/winRateを含む変更(勝敗リセットを含む)は debounce を待たずにその場で atomic write する。debounce 中に所有者が kill(更新・インストーラ)やクラッシュで落ちると、勝敗が戻るため - 勝敗のリセット: 所有者の
OverlayConfigOwner.resetWinsLosses()が行う(Manager を起動しない配信でも効く)。きっかけは 2 つ: 「起動時」(openOverlayConfigOwnerで所有者の起動時に 1 回)と「ゲーム起動検知時」(Recorder のGameProcessWatcherが星の翼の プロセスを見つけたとき。監視開始時点で既に動いていたゲームは対象外)。どちらも設定が ON で、プレイ日(朝 5 時区切り)が変わったときだけ 0勝0敗にする。勝敗が変わるたびに正本のキーwinLossPlayDayにその時点のプレイ日を残し、今日と同じなら見送る (同じ日のうちの再起動・ゲームの再起動・Steam 版とランチャー版の切り替えでは続きから数える) - 勝敗の自動加算: 試合完了(LiveEventBus
match-completed)で所有者の main のOverlayWinLossAutoUpdaterが自分の勝敗を 1 つ足す。 ON/OFF は正本のキーautoUpdateWinsLossesOnMatch(「ライブ監視」タブのスイッチ)、対象の試合種別はoverlayMatchTypeFilter。 正本を持たない委譲 Manager は足さないので二重加算しない。旧 localStoragelogWatcher_autoUpdateOverlayは管理画面の起動時に 1 回だけ移して消す - 自分の 4 キャラの自動反映: BP で自分の所持 4 キャラが確定したとき(
bp-data)と試合完了(match-completed)で、所有者の main のOverlayDeckAutoApplierがカスタムエリアのselectedImagesを書く。ON/OFF は正本のキーautoApplyMyDeckOnMatch(既定 ON。 「リアルタイム解析」タブの「Starward 自動反映」)。画像パスは管理画面と同じ形(配布版は dist 配下の file://、dev は/images/...)で、 キャラ名 → 画像の対応は生成スクリプトが出すsrc/utils/imageManifest.tsを使う。旧 localStoragerealtimeAnalysis.deckAutoApplyEnabled(と旧キーlogAnalysis_deckAutoApplyEnabled)は管理画面の起動時に 1 回だけ移して消す(spec: electron-memory-release) - 旧版からの移行: 旧版は zustand persist で localStorage の
overlay-storageに保存していた。管理画面は起動時にその内容をkind: 'seed'で送り、所有者は 自分の overlay-config.json がまだ無いときだけ 受け入れる(overlay/legacyOverlayStorage.ts)。 旧キーは消さないが、以後は読まれるだけで書かれない
ローカル WebSocket の認証(spec: security-local-ws-auth)
47831(Manager⇔Recorder の操作用)と 47832(OBS 配信)は 127.0.0.1 にしか bind しないが、同じ PC の別プロセスは Origin を付けずに(または偽って)繋げる。繋がれると操作 I/F(settings:save・watch:stop・obsRecording:* など)を 実行できてしまうため、起動ごとの乱数トークンで接続を絞る。実装は electron/services/liveEvents/localWsAuth.ts。
| ポート | 繋いでくる相手 | 通す条件 |
|---|---|---|
47831 /live・/overlay-sync | 委譲 Manager / Viewer(LiveEventWsClient / OverlayConfigRelay) | 自オリジン以外の Origin は拒否。そのうえで Origin の有無に関わらずトークン必須(ブラウザから繋ぐ経路は無い) |
47832 /overlay-sync | OBS ブラウザソース(配った overlay.html) | Origin が自オリジン(http://127.0.0.1:<port> / localhost)なら トークン不要。Origin 無し(ブラウザ以外)はトークン必須 |
- トークン: プロセスごとに
crypto.randomBytes(32)。LiveEventWsServerを listen できたプロセス(Recorder / own-writer Manager)が<userData>/local-ws-token-<port>に書き、終了時に消す(自分の書いた値のときだけ)。Manager / Viewer / Recorder は同じ userData を使う (process.lockによる委譲判定もこの前提)ので、クライアントは 接続のたびに このファイルを読む(Recorder を再起動するとトークンが変わる)。 %APPDATA% 配下はそのユーザーしか読めない。ポートごとにファイルを分けているので、dev とリリースを並べても取り違えない - 相互認証: トークンそのものは送らない。クライアントは nonce(16 バイト)と
HMAC(token, "client|nonce")をヘッダ (x-wingstats-ws-nonce/x-wingstats-ws-auth)で送り、サーバは timingSafeEqual で照合して 101 応答にHMAC(token, "server|nonce")(x-wingstats-ws-proof)を付ける。クライアントは証明が合わない相手(ポートを横取りした別プロセス)とは何もやり取りせず切断し、 再接続を続ける。同じ nonce の使い回しは拒否する - トークンが読めない間(Recorder 起動前)はクライアントは繋がずに再接続を待つ。認証に失敗した upgrade は 401
- OBS の URL をそのまま使えるようにした理由: OBS のブラウザソースは 47832 が配った
overlay.htmlを開き、同じオリジンで/overlay-syncに繋ぐ。Web ページは Origin を偽れないので、自オリジンの Origin が付く接続はそのページだけ(DNS rebinding は Host / Origin の 両検査で弾く)。配信 HTML にトークンを埋める案は、/overlay.htmlを GET できる別プロセスにもトークンが渡るので守りが増えず、 ユーザーが OBS に貼った URL を変える案は既存の設定を壊すので採らなかった。47832 で別プロセスにできることは、下の検査を通る オーバーレイ設定の変更までに絞っている - 受信上限: 両ポートとも 1 メッセージ 512 KiB(
LOCAL_WS_MAX_PAYLOAD。ws 既定は 100 MiB)。実際に流れるのは設定の全量(数 KB)や コマンド(数百 B)だけ。超えたら 1009 で切断する - 正本に入れてよいキー:
OverlayConfigOwnerは WS / IPC のどこから来たパッチ・SEED もoverlayConfigSchema.tsで検査し、LAYOUT_DEFAULTSのキーで型が既定値と合う値だけを入れる(renderer のALLOWED_KEYSと同じキー)。許可外のキーは捨てて warn する settings:saveに書けるキー:remoteSettingsPatch.tsの許可リストと型で確かめ、1 つでも外れたらパッチ全体を受け付けない (Manager のUserSettingsServiceは{ok:false}を受けて自分で書くので、正規の操作で設定は失われない)。OBS 録画の接続先と パスワード(obsReplayRecording)はここでは書けず、obsRecording:set-configだけが変える- OBS 録画のパスワード:
obsRecording:set-configで接続先のホスト / ポートが変わったのに新しいパスワードが一緒に来なければ、 保存済みパスワードを消す(接続先を他人のサーバに向けてtestさせ、保存済みパスワードで認証応答を作らせる攻撃を防ぐ)
デバッグ用関数(renderer console)
| 関数 | 役割 |
|---|---|
window.debugOverlay() | 現在の store snapshot を console に出す |
window.dumpOverlayStorage() | 旧 localStorage(移行元)と現在のストアを表示 |
window.clearOverlayStorage() | 旧 localStorage(移行元)を消す。現在の設定には影響しない |
window.applyLayoutDefaults() | 全項目を LAYOUT_DEFAULTS に戻す(正本にも反映) |
テスト戦略
src/store/__tests__/: store 公開 API のテストsrc/store/sync/__tests__/: 各 adapter の単体テスト(mock event を流す)src/store/overlay/sync.test.ts:broadcastSet/ receive guard /isReceivingフラグの境界src/store/overlay/actions.test.ts: setter のロジック
initStoreSync(mockAdapter) で adapter を DI できるので、UI 側のテストでも分離可能。
関連ドキュメント
- 全体構成: Source Architecture
- IPC まわり: Electron Main
Last verified: 2026-10-24 / commit e111fbeb