Skip to content

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 インターフェース ​

ts
// 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用途トランスポート
ElectronAdapterElectron control / overlay window 間の同期window.electronAPI.syncConfig / onConfigUpdated(IPC)
BroadcastAdapter同一ブラウザ・同一 origin 内の複数タブBroadcastChannel
WSAdapterOBS ブラウザソース ↔ 開発サーバの相互通信WebSocket
LocalStorageAdapterOBS local file mode 等のフォールバックlocalStorage + storage イベント

CompositeAdapter ​

複数 adapter を 1 つの SyncAdapter として扱う ためのラッパ。

ts
// 抜粋: 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 する。

ts
// 抜粋: 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 は足さないので二重加算しない。旧 localStorage logWatcher_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 を使う。旧 localStorage realtimeAnalysis.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-syncOBS ブラウザソース(配った 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 側のテストでも分離可能。

関連ドキュメント ​


Last verified: 2026-10-24 / commit e111fbeb