[Electron] #02 Live2Dデスクトップマスコット — VS Code拡張とWebSocketでつなぎ、日本語ZIPとLinux配布まで

[Electron] #02 Live2Dデスクトップマスコット — VS Code拡張とWebSocketでつなぎ、日本語ZIPとLinux配布まで

はじめに

前回の記事の最後に「次は VS Code 拡張を作って WebSocket でつなぐ」と書きました。今回はその続きです。

できたものは次のとおりです。

  • VS Code でファイルを保存する、エラーを出す/全部消す、デバッグを始める、ビルドが成功・失敗する、といった出来事にキャラクターが反応する
  • マスコットを後から起動しても、VS Code 側が勝手につなぎ直す。WSL のウィンドウでも動く
  • 日本語のファイル名を含むモデル ZIP が読めなかった問題を修正
  • 余白の多いモデルで吹き出しが頭から離れていた問題を修正
  • 設定画面をキャラクターに重ならない別ウィンドウに
  • Linux(AppImage / deb)を加えて、v1.2.0 として3 OS で配布

11時半に始めて、15時ちょうどに v1.2.0 を公開しました。今回も地雷の記録です。

スクリーンショット

VS Code のエラーに反応するマスコット 別ウィンドウになった設定画面

リポジトリ・ダウンロード


1. 全体の流れ

[VS Code 拡張]  保存・エラー数・デバッグ・タスクの結果
      │ WebSocket(ws://127.0.0.1:<port>/?token=…)
      ▼
[メインプロセス]  bridge.js:受け取って検証するだけ
      │ IPC(bridge-event)
      ▼
[レンダラー]  app.js:しゃべるか、どう動くかを決める

拡張機能は「何が起きたか」という事実だけを送り、反応するかどうか、何を言うかはマスコット側が決めます。前回決めたこの分担のおかげで、拡張機能側はかなり単純になりました。

メッセージはフラットな JSON にしました。

{ "v": 1, "type": "diagnostics", "payload": { "errors": 3, "warnings": 1 }, "ts": 1759110000000 }

v は最初から入れておきました。あとで形式を変えたくなったとき、古い拡張機能と新しいマスコットが混ざっても判別できます。


2. ポート番号をどう伝えるか:設定ファイルと接続情報ファイルを分ける

WebSocket の待ち受けポートを固定にすると、ほかのアプリとぶつかる可能性があります。そこで、空いているポートを OS に選ばせて(port: 0)、実際のポート番号をファイルに書き出し、拡張機能にはそのファイルを読んでもらうことにしました。

最初は「拡張性を持たせるために、設定 JSON を1つ作ってそこにまとめたほうがいい」と考えていました。ただ、ポート番号は起動するたびに変わる値です。人が編集する設定ファイルに入れると、アプリが起動のたびにそのファイルを書き換えることになり、終了後も古いポート番号が残ります。

そこで2つに分けました。

ファイル書く人中身寿命
config.json人連携のオン/オフ、希望ポート(今後の設定もここに)ずっと
bridge.jsonアプリ実際のポート、トークン、PID起動時に作成、終了時に削除

どちらも Electron の userData(Windows なら %APPDATA%\Live2D Desktop Mascot\)に置きます。

config.json は、足りないキーを既定値で補って書き戻します。バージョンアップで設定項目が増えたとき、ファイルを開けば新しい項目が見えるようにするためです。ただし、JSON が壊れているときは上書きせず、既定値で起動します。手で編集している途中のファイルを消してしまわないためです。

// src/main/config.js(抜粋)
const DEFAULTS = {
  configVersion: 1,
  bridge: {
    enabled: true,
    port: 0        // 0 = 空いているポートを自動で使う
  }
};

// 既定値に user の値を重ねる(型が違う値は捨てる)
function merge(def, user) {
  const out = {};
  for (const [k, d] of Object.entries(def)) {
    const u = user?.[k];
    if (isObj(d)) out[k] = merge(d, isObj(u) ? u : {});
    else out[k] = typeof u === typeof d ? u : d;
  }
  // 知らないキーも残す(新しい版で追加された設定を古い版が消さないように)
  for (const [k, u] of Object.entries(user || {})) if (!(k in def)) out[k] = u;
  return out;
}

3. マスコット側:ブラウザからつながせない

WebSocket サーバーは ws パッケージで作り、メインプロセスに置きました。守りたいのは「同じ PC 上のブラウザで開いたページから、勝手につながれないこと」です。

  • 127.0.0.1 にだけバインドする(LAN の外からは届かない)
  • Origin ヘッダーが付いた接続は拒否する(ブラウザは必ず付ける。Node の拡張機能は付けない)
  • bridge.json に書いたトークンが URL に付いていなければ拒否する
// src/main/bridge.js(抜粋)
const wss = new WebSocketServer({
  server,
  maxPayload: 64 * 1024,
  verifyClient: (info, cb) => {
    if (info.origin) return cb(false, 403, 'Forbidden');   // ブラウザからの接続
    const t = new URL(info.req.url, 'http://x').searchParams.get('token');
    if (!t || !safeEqual(t, token)) return cb(false, 401, 'Unauthorized');
    cb(true);
  }
});

bridge.json は一時ファイルに書いてからリネームし、中途半端な状態のファイルを読まれないようにしています。終了時には、中のトークンが自分のものと一致するときだけ削除します。

設定でポートを固定したときに、そのポートが使われていたら、自動のポートに切り替えます。拡張機能はどうせ bridge.json を読むので、ポートが変わっても困りません。

接続ごとに番号を振る

ここで1つ気づいたことがあります。VS Code はウィンドウごとに拡張機能のホストが立つので、ウィンドウを3つ開いていれば、接続も3本来ます。「エラーが3件」という情報も、どのウィンドウの話なのかを区別しないと、ウィンドウを切り替えるたびに件数が行ったり来たりします。

そこで、サーバー側で接続ごとに番号を振り、connect / disconnect もイベントとしてレンダラーに送ることにしました。


4. 反応はマスコット側で決める

最初は「エラーが増えたときだけ拡張機能から送る」つもりでした。しかしそれだと、「エラーが全部消えた」ことを伝えられません。

そこで方針を変えて、拡張機能はエラー数が変わるたびに(1.5秒デバウンスして)送るだけにし、しゃべるかどうかはマスコット側で前回の値と比べて決めるようにしました。

// src/renderer/app.js(抜粋)
case 'diagnostics': {
  const errors = Math.max(0, Number(payload.errors) || 0);
  const prev = diagByClient.get(client);
  diagByClient.set(client, errors);
  if (prev === undefined) break; // 接続直後のスナップショットは基準にするだけ
  if (errors > prev) react('error', BRIDGE_LINES.error(errors));
  else if (prev > 0 && errors === 0) react('fixed', BRIDGE_LINES.fixed, { force: true });
  break;
}

接続した直後に届く最初の値は、比較の基準にするだけでしゃべりません。そうしないと、エラーが残っているプロジェクトを開くたびに「エラーが12件あるよ」と言われます。

保存は頻繁に起きるので、反応は35%の確率で、しかも45秒に1回までにしました。反応どうしの間隔も最低5秒空けています。この数字は今のところ勘なので、しばらく使いながら調整します。

前回作ったボイスパックの on(場面)もそのまま使えます。"on": ["error"] や "on": ["fixed"] の声を用意すれば、その場面ではセリフの代わりに声で反応します。


5. VS Code 拡張:依存ゼロで書く

拡張機能は TypeScript も yo code も使わず、package.json と extension.js の2ファイルだけで書きました。

WebSocket のクライアントには、VS Code 1.101 以降の Node 22 に標準で入っている WebSocket を使っています。これなら npm install も要りません。

ここで1つ確認が必要でした。マスコット側は Origin ヘッダー付きの接続を拒否します。ブラウザと同じ API の WebSocket が、Node の中でも Origin を付けてきたら、自分の拡張機能が弾かれてしまいます。実際に試したところ、Node 22 の標準 WebSocket は Origin を付けませんでした。

origin: undefined
headers: host,connection,upgrade,sec-websocket-key,sec-websocket-version,...

送るイベントは VS Code の API をそのまま使っています。

拡張機能が拾うもの送るイベント
workspace.onDidSaveTextDocumentsave
languages.onDidChangeDiagnosticsdiagnostics(件数が変わったときだけ)
debug.onDidStartDebugSessiondebugStart(子セッションは除く)
tasks.onDidEndTaskProcesstaskEnd(exit code 付き)

マスコットが起動していないときや、途中で終了したときは、5秒おきに bridge.json を読み直してつなぎ直します。bridge.json に書いてある PID のプロセスが生きているかも process.kill(pid, 0) で確かめて、マスコットが落ちて古いファイルだけが残っている場合はつなぎに行きません。

WSL のウィンドウでは拡張機能が Linux 側で動く

拡張機能を試そうとしたとき、開発ホストのウィンドウが前回開いていた WSL のプロジェクトを開きました。これで気づいたのですが、WSL やリモート接続のウィンドウでは、拡張機能は既定でリモート側(WSL なら Linux)で動きます。そうなると、bridge.json を Linux 側で探しに行き、127.0.0.1 も Windows のマスコットには届きません。

package.json に1行足すと、拡張機能は常に手元の PC 側で動きます。

"extensionKind": ["ui"]

保存やエラーの情報は、UI 側の拡張機能ホストにも届くので、これで WSL のプロジェクトで作業していてもマスコットが反応します。

F5 を押しても拡張機能が読み込まれない

もう1つ、つまずいたところがあります。拡張機能をデバッグ実行する設定(launch.json)を vscode-extension/.vscode/ に置いていたのですが、リポジトリのルートを開いたまま F5 を押すと、それが読まれません。package.json を開いた状態だったので、VS Code は「JSON をデバッグする拡張機能がありません」と言ってきました。

launch.json をルートに置き、拡張機能のフォルダを指すようにしました。

{
  "name": "VS Code 拡張を実行",
  "type": "extensionHost",
  "request": "launch",
  "args": ["--extensionDevelopmentPath=${workspaceFolder}/vscode-extension"]
}

ところが、ルートの .gitignore に .vscode/* があって、今度は git に入りません。!.vscode/launch.json を足して管理対象に戻しました。

これで、マスコットのコードと拡張機能のコードを同じウィンドウで触りながら、F5 で両方を試せるようになりました。

テスト送信用のスクリプトと PowerShell のクォート

拡張機能ができる前に動作を確かめるため、イベントを送るだけのスクリプト(scripts/bridge-send.js)を用意しました。最初は引数に JSON をそのまま渡す形にしていました。

node scripts/bridge-send.js say "{\"text\":\"テスト\"}"

これが PowerShell では動きませんでした。PowerShell では \" がエスケープにならないので、スクリプトには {\ だけが渡っていました。PowerShell のバージョンによってクォートの扱いも変わるので、JSON を渡すのをやめて key=value の形にしました。

node scripts/bridge-send.js say テストだよ
node scripts/bridge-send.js diagnostics errors=0 errors=3 errors=0
node scripts/bridge-send.js taskEnd name=build,exitCode=1

6. 日本語のファイル名のZIPが読めない:原因は2つあった

連携とは別に、以前から「一部のモデルが読み込めない」という問題がありました。マスコットでも、サイトの Live2D Viewer でも起きていて、特にファイル名に日本語が入っているとだめでした。マスコットのエラーはこうでした。

ENOENT: no such file or directory, open '...\models\yukari_3\motion\�K�b�c�|�[�Y.motion3.json'

原因1:ZIP の中のファイル名が Shift_JIS

�K�b�c�|�[�Y は「ガッツポーズ」です。日本語版 Windows のエクスプローラーなどで作った ZIP は、ファイル名を Shift_JIS で保存していて、「このファイル名は UTF-8 です」という印(EFS フラグ)も付いていません。それを UTF-8 として読むとこうなります。ガ(83 4B)の 83 が UTF-8 として読めないので � になり、4B がそのまま K になる、という化け方です。

Viewer 側には、別の AI と直した跡としてこういうコードが入っていました。

// 効いていなかったコード
decodeFileName: (bytes) => {
  try {
    return new TextDecoder('utf-8').decode(bytes);
  } catch {
    return new TextDecoder('shift-jis').decode(bytes);
  }
}

一見よさそうですが、catch の中に来ることは一度もありません。TextDecoder は、読めないバイトを � に置き換えて何事もなかったように返すので、例外を投げないからです。例外を投げさせるには fatal: true が必要です。

// 修正後(マスコット:adm-zip の decoder に渡す)
const utf8Strict = new TextDecoder('utf-8', { fatal: true });
const sjis = new TextDecoder('shift_jis');

function decodeZipName(buf) {
  let name;
  try { name = utf8Strict.decode(buf); }
  catch { name = sjis.decode(buf); }
  return name.replace(/\\/g, '/').normalize('NFC');
}

const zip = new AdmZip(zipPath, {
  decoder: { efs: false, encode: (s) => Buffer.from(s, 'utf8'), decode: decodeZipName }
});

UTF-8 として厳密に読めなければ Shift_JIS とみなします。日本語の Shift_JIS のバイト列が、たまたま正しい UTF-8 になることはほとんどありません。ついでに、区切り文字の \ を / にそろえ、Mac で作った ZIP の濁点が分かれた文字(NFD)を NFC にそろえています。

原因2:pixi-live2d-display の validateFiles が日本語で必ず失敗する

文字化けを直しても、Viewer ではまだ別のエラーが出ていました。

File "春日部つむぎ公式live2Dモデル.moc3" is defined in settings, but doesn't exist in given files

ファイル名は正しく表示されています。pixi-live2d-display 0.4.0 のソースを追うと、ファイルがそろっているかを確認する validateFiles が、次の2つを比べていました。

// pixi-live2d-display 0.4.0(抜粋)
settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath)));
// ...
validateFiles(files) {
  const actualPath = this.resolveURL(expectedFile);   // url.resolve の結果
  if (!files.includes(actualPath)) throw new Error(`File "${expectedFile}" is defined in settings, but doesn't exist in given files`);
}

片方は url.resolve の結果で、日本語は生のまま。もう片方は encodeURI した結果で、日本語は %E6%98%A5… になっています。実際に比べてみました。

expected: model_root/春日部つむぎ公式live2Dモデル.moc3
listed  : model_root/%E6%98%A5%E6%97%A5%E9%83%A8%E3%81%A4%E3%82%80%E3%81%8E...
match: false

スペースは両方とも %20 になるので一致しますが、日本語が1文字でも入っていると、ZIP でもフォルダでも、文字コードが何であっても必ず失敗します。ファイルを実際に割り当てる upload のほうは decodeURI してから比べているので、壊れているのは確認の1か所だけでした。

Viewer では、この確認処理を差し替えています。

// live2d-model-viewer.astro(抜粋)
const key = (p) => {
  let s = String(p);
  try { s = decodeURI(s); } catch {}
  return s.replace(/\\/g, '/').normalize('NFC');
};

PIXI.live2d.ModelSettings.prototype.validateFiles = function (files) {
  const have = new Set(files.map(key));
  const exists = (file, required) => {
    if (have.has(key(this.resolveURL(file)))) return true;
    if (required) throw new Error(`File "${file}" is defined in settings, but doesn't exist in given files`);
    return false;
  };
  [this.moc, ...this.textures].forEach((f) => exists(f, true));
  return this.getDefinedFiles().filter((f) => exists(f, false));
};

Cubism 2 用と Cubism 4 用のモデル設定クラスは、どちらもこのメソッドを上書きしていないので、親クラスの1か所を差し替えれば両方に効きます。

マスコットはモデルを file:// の URL で読むので、この経路は通りません。マスコットで起きていたのは原因1だけで、Viewer では原因1と2が重なっていました。


7. 吹き出しが頭から離れる:キャンバスの余白

あるモデルを読み込むと、吹き出しが頭よりずっと上に出て、キャラクターも小さく表示されました。

Live2D のモデルは「キャンバス」という台紙の上に描かれています。pixi-live2d-display の width、height、getBounds() が返すのは、この台紙全体の大きさです。キャラクターが実際に描かれている範囲ではありません。台紙の上に余白を大きく取っているモデルだと、吹き出しは余白の上端に出ます。

そこで、読み込んだときに全パーツの頂点を調べて、実際に描かれている範囲を測るようにしました。

// adapters/live2d.js(抜粋)
_measureContent(model) {
  const im = model.internalModel;
  const core = im.coreModel;
  im.pose?.updateParameters(core, 0); // pose3.json の差分パーツ(腕の切り替え等)を片方だけ表示に
  core.update();                      // 初期ポーズで頂点を計算させる
  const lt = im.localTransform;
  let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
  for (let i = 0; i < core.getDrawableCount(); i++) {
    if (core.getDrawableOpacity(i) < 0.01) continue;              // 透明なパーツは除く
    if (!core.getDrawableDynamicFlagIsVisible(i)) continue;       // 非表示のパーツも除く
    const v = im.getDrawableVertices(i);
    for (let k = 0; k < v.length; k += 2) {
      const x = lt.a * v[k] + lt.c * v[k + 1] + lt.tx;
      const y = lt.b * v[k] + lt.d * v[k + 1] + lt.ty;
      if (x < l) l = x; if (x > r) r = x;
      if (y < t) t = y; if (y > b) b = y;
    }
  }
  // モーションで少しはみ出す分の余裕を足す
  const mx = (r - l) * 0.04, my = (b - t) * 0.02;
  return { left: l - mx, top: t - my, right: r + mx, bottom: b + my };
}

腕を上げたポーズと下げたポーズを切り替えるモデルは、両方の腕が別パーツとして入っていて、pose3.json でどちらか一方だけを表示します。初期状態では両方とも不透明なので、そのまま測ると横幅が広くなりすぎます。先に pose を1回適用してから測っています。

測った範囲は、表示サイズ・位置・吹き出し・VS Code の枠内に収める判定のすべてに使います。前回「見えない壁」をなくすために入れた、描画範囲での押し戻しの精度も上がりました。


8. 設定画面を別ウィンドウにする

設定画面は、マスコットのウィンドウの中に置いたモーダルでした。キャラクターの上に重なるので、サイズや不透明度を変えても、どう変わったのかが見えません。

「設定画面をドラッグで動かせるようにする」ことも考えましたが、マスコットのウィンドウはキャラクターぴったりの大きさに縮めてあるので、中で動かしてもキャラクターの上から出られません。ウィンドウを広げると、足元基準の配置、VS Code の枠内への押し戻し、クリック透過の判定と全部ぶつかります。

そこで、設定をクレジット画面と同じ普通のウィンドウにしました。

  • タイトルバーで好きな場所に動かせる。最初はマスコットの横(左に空きがあれば左、なければ右)に開く
  • 閉じた位置を覚えておき、次もそこに開く。モニター構成が変わって見えない位置になっていたら使わない
  • サイズや不透明度を変えると、その場でキャラクターに反映される

設計で気をつけたのは、値の持ち主を1か所にすることです。設定の値とその反映は、これまでどおりマスコット側(app.js)が持ちます。設定ウィンドウは「サイズを300にして」という操作を送るだけで、表示はマスコットから返ってきた状態を描き直します。

設定ウィンドウ ──settings-action──▶ メイン ──▶ マスコット(値を反映)
設定ウィンドウ ◀──settings-state─── メイン ◀── マスコット(今の状態)

こうしておくと、キャラクターの上で Ctrl+ホイールでサイズを変えたときも、設定ウィンドウのスライダーが追従します。ドラッグ中のスライダーは、状態が返ってきても上書きしないようにしています。上書きすると、つまみが指から逃げます。

今回は Electron と仮想ディスプレイ(Xvfb)を使って実際に起動し、設定ウィンドウからの操作がマスコットに反映されること、閉じて開き直すと同じ位置に出ることを確認しました。


9. Cubism 5.3 形式のモデル:今は「未対応」と分かるようにするだけ

Live2D 公式サンプルの新しいモデル「レン・フォスター」を読み込むと、Unknown error で失敗しました。コンソールにはこう出ていました。

[CSM] [E]csmReviveMocInPlace is failed. The Core unsupport later than moc3 ver:[5]. This moc3 ver is [6].

レン・フォスターは Cubism 5.3 で作られたモデルで、.moc3 の形式がバージョン6です。今使っている Cubism Core 5.1 は5までしか読めません。

では Core を上げればいいかというと、前回の「Cubism Core 6 で pixi-live2d-display が動かない」問題にぶつかります。調べ直すと、5.3 に対応した Core では、描画順を取る API が drawables.renderOrders から getRenderOrders() に変わっていました(Core の C API では csmGetDrawableRenderOrders が廃止され、csmGetRenderOrders が追加されています)。5.3 で加わったブレンドモードとオフスクリーン描画は、描画の仕組みそのものを変える機能で、公式も古い描画方式では正しく表示できないと書いています。レン・フォスターは、まさにその新機能を見せるためのサンプルです。

pixi-live2d-display は更新が止まっているので、本気で対応するなら描画を公式の Cubism SDK for Web に入れ替えることになります。これは今回の連携と同じか、それ以上の作業です。今出回っているモデルの大半はまだバージョン5以下なので、今回は「何がだめなのか分かるようにする」だけにしました。

.moc3 は先頭4バイトが MOC3、その次の1バイトが形式のバージョンです。

4d 4f 43 33 01 00 00 00    M O C 3 [1]   ← ハル(受付版)はバージョン1

読み込む前にこの1バイトを見て、Core が読める最新のバージョン(csmGetLatestMocVersion())と比べます。上限を決め打ちにしないので、将来 Core を上げれば判定もそのまま追従します。

// adapters/live2d.js(抜粋)
static async checkMocVersion(settingsUrl) {
  const json = await xhrGet(settingsUrl, 'json');
  const moc = json?.FileReferences?.Moc;
  if (!moc) return;
  const bytes = new Uint8Array(await xhrGet(new URL(moc, settingsUrl).href, 'arraybuffer'), 0, 5);
  if (String.fromCharCode(...bytes.slice(0, 4)) !== 'MOC3') return;
  const version = bytes[4];
  const latest = window.Live2DCubismCore?.Version?.csmGetLatestMocVersion?.() ?? 5;
  if (version > latest) {
    const err = new Error(`moc3 ver ${version} には未対応です(このアプリは ver ${latest} まで)`);
    err.code = 'UNSUPPORTED_MOC';
    throw err;
  }
}

fetch ではなく XHR を使っているのは、file:// の URL を fetch では読めないからです。

マスコットでは「このモデルは Cubism 5.3 以降の形式で、まだ対応していないよ」と吹き出しで伝えて、デフォルトのモデルに戻ります。Viewer ではエラー欄に同じ内容を出します。

この処理はアダプタ(live2d.js)の中に置きました。描画を入れ替えるときに書き直すのをこのファイルだけにしておくため、Live2D に直接触る処理は必ずアダプタに入れる、というルールにしています。いずれ入れ替えるときには、AI も今より賢くなって、知見も増えているはずです。


10. v1.2.0 のリリース

3台同時ビルド

Electron のアプリは、それぞれの OS 上でしかビルドできません。Windows はそのまま、Mac と Debian 12(XFCE)はリモートデスクトップでつないで、3台で同時に npm run dist:* を回しました。待ち時間はほぼ1台分です。

GitHub Actions で自動ビルドすることも考えましたが、見送りました。自動ビルドにしても、各 OS で起動して確かめる作業は残ります。それに、Cubism Core とサンプルモデルは再配布物なのでリポジトリに入れておらず、自動ビルドでは毎回どこかから取ってくる仕組みが要ります。サンプルモデルは規約に同意してからダウンロードする形なので、自動化しにくいのです。git pull・npm install・npm run dist を回して、そのまま起動して確かめるほうが早いと判断しました。

各 OS で確かめたのは次の5つです。

  1. 起動して、キャラクターと吹き出しが出る
  2. 設定が別ウィンドウで開き、サイズを変えるとキャラクターに反映される
  3. 日本語名のモデル ZIP を取り込める
  4. 拡張機能を入れた VS Code でエラーを出して消すと反応する
  5. 終了すると bridge.json が消える

拡張機能は Marketplace に出さない

拡張機能は .vsix にして Release に添付するだけにし、VS Code Marketplace には出していません。マスコットがないと何もしない専用の拡張機能なので、Marketplace で見つけた人が入れても「何も起きない拡張」になってしまいます。反応の頻度もまだ勘の値です。バージョンも 0.1.0 にして、まだ試作だと分かるようにしました。

cd vscode-extension
npx @vscode/vsce package      → live2d-mascot-bridge-0.1.0.vsix

ZIP をコミットしかけた

作業の途中で、git log origin/main..main --stat を見たら、まだ push していないコミットに Live2D-Desktop-Mascot.zip が入っていました。ソースを渡すために作った ZIP を、そのまま git add していたのです。

この ZIP には、再配布してはいけない vendor/live2dcubismcore.min.js が入っていました。README にも「リポジトリには含めていない」と書いているファイルです。push する前だったので、git reset origin/main でコミットだけを取り消しました(作業中のファイルはそのまま残ります)。.gitignore に *.zip・*.patch・*.vsix を足して、同じことが起きないようにしています。

今回は作業ブランチを切って、プルリクエストから main にマージする流れも初めてやってみました。ひとりで開発していると main しか触りませんが、AI から ZIP や差分をまとめて受け取るときは、main に入れる前にプルリクエストの画面で差分を一度に見られるので、こういう事故に気づきやすくなります。


まとめ

  • VS Code 拡張とマスコットは WebSocket でつなぐ。127.0.0.1 にだけバインドし、Origin 付きの接続を拒否し、トークンで認証すれば、ブラウザからは入られない。
  • 人が編集する config.json と、アプリが書く bridge.json(ポート・トークン・PID)は分ける。寿命と書き手が違うものを1つのファイルにしない。
  • 拡張機能は事実を送るだけにして、反応はマスコット側で決める。エラー数は変化のたびに送り、比較はマスコット側でする。VS Code のウィンドウごとに接続が来るので、状態は接続ごとに持つ。
  • Node 22 の標準 WebSocket は Origin を付けないので、依存ゼロの拡張機能が書ける。WSL で使うなら "extensionKind": ["ui"]。
  • TextDecoder は既定では例外を投げない。Shift_JIS への切り替えには { fatal: true } が要る。
  • pixi-live2d-display 0.4.0 の validateFiles は、日本語のファイル名で必ず失敗する。比べる両側をデコードしてそろえて比べる。
  • getBounds() はキャンバス全体の大きさ。吹き出しやサイズは、頂点から測った描画範囲で決める。
  • Cubism 5.3 形式(moc3 ver 6)は、先頭の1バイトで判定して未対応と伝える。本格対応は描画の入れ替えになるので、Live2D に触る処理はアダプタに閉じ込めておく。

次は、しばらく普段使いして、反応の頻度やセリフを調整します。