[Electron] #01 Live2Dデスクトップマスコットをゼロから作る — VS Code拡張からの方針転換とWindows/macOS配布まで

[Electron] #01 Live2Dデスクトップマスコットをゼロから作る — VS Code拡張からの方針転換とWindows/macOS配布まで

はじめに

VS Code でコードを書いている横に、Live2D のキャラクターが立っていてほしい。

最初は「VS Code 拡張機能」として作り始めました。ところが途中で、それでは本当にやりたいことが実現できないことに気づき、Electron 製の単体デスクトップアプリに方針を変えました。

Tauri でのデスクトップアプリは何度か作ってきましたが、Electron でゼロから作って配布まで持っていくのは今回が初めてです。

最終的にできたものは次のとおりです。

  • VS Code のウィンドウ枠の中に留まって常駐する(フリーモードで自由移動も可)
  • キャラクター以外の透明部分は、クリックが下のアプリに抜ける
  • マウスカーソルを目で追う、クリックやダブルクリックで反応する、時報、独り言
  • モデルの切り替え(ZIP・フォルダの取り込み)
  • 声・字幕・しぐさをまとめたボイスパック、音量に合わせた口パク
  • 読み上げは VOICEVOX(起動していれば)か OS 標準音声
  • Windows(インストーラー/ポータブル)と macOS(Apple Silicon/Intel)で配布

家事をしながら、昼過ぎに始めて夜7時には v1.1.0 を公開していました。この記事は、その間に踏んだ地雷の記録です。

スクリーンショット

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


1. VS Code拡張では「浮かせられない」

VS Code 拡張機能が描画に使える場所は、決まった枠の中だけです。

場所中身
Webview パネルエディタのタブの1つ
Webview Viewサイドバーや下部パネルの中
ステータスバーテキストとアイコンだけ

どれも VS Code の中の決められた領域で、エディタの上に透明なウィンドウを重ねてキャラクターを立たせることはできません。エディタに重ねる既存の拡張機能もありますが、それらは VS Code 本体の workbench.html を書き換えて差し込んでいます。「インストールが壊れています」という警告が出て、VS Code を更新するたびに消える方式です。

そこで役割を分けることにしました。

[マスコット本体(Electron)]  透明ウィンドウで常駐・描画・音声
         ↑ WebSocket(localhost)  ※次の段階
[VS Code 拡張]                保存・エラー数・ビルド結果などのイベントを送るだけ

キャラクターを浮かせるのはデスクトップアプリの仕事で、エディタの中で起きたことを取れるのは拡張機能の仕事です。拡張機能はマスコットに「事実」だけを送り、どう反応するかはマスコット側が決める。この構成なら、将来 Tauri に移植しても、ほかのツールからつないでも、拡張機能側は変えずに済みます。

今回はまず、マスコット本体を単体で完成させました。


2. 全体の構成

src/main/main.js            メインプロセス(ウィンドウ・トレイ・IPC・VOICEVOX)
src/main/library.js         モデルライブラリ(ZIP/フォルダ取り込み・一覧・削除)
src/main/tracker/index.js   VS Code位置追跡の振り分け(OS別)
src/main/tracker/win32.js   Windows実装(PowerShell常駐)
src/renderer/index.html
src/renderer/app.js         吹き出し・イベント・設定・入力・音声(描画方式に依存しない)
src/renderer/adapters/live2d.js   Live2D描画アダプタ
assets/<モデル>/            同梱モデル(Live2D公式サンプル「ハル」2体)
assets/voices/<名前>/       ボイスパック
vendor/                     Live2D Cubism Core

描画部分は「アダプタ」として切り出してあります。Live2D でも VRM でも、やることは「読み込む・大きさを決める・視線を向ける・モーションを再生する・口を動かす・当たり判定」の同じ操作なので、同じインターフェースにそろえておけば、描画方式を差し替えるだけで済みます。

// adapters/live2d.js のメソッド(VRM版も同じ形にする予定)
load(url) / setHeight(px) / getSize() / setPosition(x, y) / getBounds()
focus(x, y) / resetFocus() / listMotions() / playMotion(m) / setExpression(name)
setMouth(level) / setModelSound(on) / hitTest(x, y) / dispose()

ウィンドウ制御、クリック透過、吹き出し、音声、時報といった機能はすべてアダプタの外にあるので、描画方式に関係なく使い回せます。


3. VS Codeの枠内に留める

最初の版には「キャラクターが VS Code の枠外に出る」というバグがありました。原因は3つ重なっていました。

原因1:押し戻し処理が「カーソル位置への移動」になっていた

VS Code の座標を受け取った側が、枠と比較せずに move-window に { mouseX: 0, mouseY: 0 } を送っていました。これは「ウィンドウの左上をマウスカーソルの位置に動かす」処理なので、毎秒キャラクターがカーソルに吸い寄せられていました。

原因2:PowerShellのパイプが消えていた

JavaScript の文字列の中に書いた PowerShell コマンドに \vert{} が混入していて、\v が垂直タブとして解釈され、パイプ | が消えていました。コマンドが毎回失敗していたので、座標は一度も届いていませんでした。

原因3:座標系のずれ

  • GetWindowRect は物理ピクセルを返すが、Electron は DIP(論理ピクセル)で動く。スケーリング100%以外でずれる
  • 最大化時は、見えない枠の約8pxぶんが含まれる
  • 最小化中は -32000 が返る

修正:PowerShellを常駐させて200msごとに出力する

毎秒 Add-Type でC#をコンパイルし直すのは重いので、PowerShell を1つ常駐させて、座標を標準出力に流し続ける形にしました。GetWindowRect の代わりに DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)を使うと、見えない枠を含まない見た目どおりの矩形が取れます。

// src/main/tracker/win32.js(抜粋)
const PS_SCRIPT = `
$ProgressPreference = 'SilentlyContinue'
Add-Type @"
using System;
using System.Runtime.InteropServices;
public struct RECT { public int Left, Top, Right, Bottom; }
public static class W {
  [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v);
  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
  [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
  [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int a, out RECT r, int s);
}
"@
[W]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null
$h = [IntPtr]::Zero
while ($true) {
  if ($h -eq [IntPtr]::Zero -or -not [W]::IsWindow($h)) {
    $p = Get-Process -Name Code -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
    if ($p) { $h = $p.MainWindowHandle } else { $h = [IntPtr]::Zero }
  }
  if ($h -ne [IntPtr]::Zero -and -not [W]::IsIconic($h)) {
    $r = New-Object RECT
    [W]::DwmGetWindowAttribute($h, 9, [ref]$r, 16) | Out-Null
    [Console]::Out.WriteLine("$($r.Left),$($r.Top),$($r.Right - $r.Left),$($r.Bottom - $r.Top)")
  } else {
    [Console]::Out.WriteLine("none")
  }
  [Console]::Out.Flush()
  Start-Sleep -Milliseconds 200
}
`;

function start(onRect) {
  // -EncodedCommand(UTF-16LE の Base64)で渡せばエスケープ地獄から解放される
  const encoded = Buffer.from(PS_SCRIPT, 'utf16le').toString('base64');
  const proc = spawn('powershell.exe',
    ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
    { windowsHide: true });
  // …標準出力を1行ずつ読んで onRect({x, y, width, height}) または onRect(null)
}

$ProgressPreference = 'SilentlyContinue' は、Add-Type が標準エラーに #< CLIXML という進捗情報を吐き出すのを止めるためのものです。

メインプロセスでは、受け取った物理ピクセルの矩形を screen.screenToDipRect で DIP に変換してから比較します。

// src/main/main.js(抜粋)
trackerHandle = tracker.start((rect) => {
  vscodeRect = rect ? screen.screenToDipRect(null, rect) : null;
  clampToVSCode();
});

「見えない壁」をなくす:キャラクターの描画範囲で判定する

ウィンドウの枠で判定すると、キャラクターの周りの透明な余白のぶんだけ、何もない場所で止まる「見えない壁」ができます。そこで、レンダラーでモデルの描画範囲(getBounds())を測ってメインプロセスに送り、その範囲が VS Code の中に収まるように押し戻す形にしました。

// (x, y) をVS Code領域内に収めた座標を返す(判定はキャラの描画範囲)
function clampPosition(x, y) {
  if (!isTrackingMode || !vscodeRect || !mainWindow) return { x, y };
  const [w, h] = mainWindow.getSize();
  const ins = modelInset || { left: 0, top: 0, right: w, bottom: h };
  const r = vscodeRect;
  const clamp1 = (v, min, max) => (max < min ? min : Math.min(Math.max(v, min), max));
  return {
    x: Math.round(clamp1(x, r.x - ins.left, r.x + r.width - ins.right)),
    y: Math.round(clamp1(y, r.y - ins.top, r.y + r.height - ins.bottom))
  };
}

透明な部分は VS Code の外にはみ出してかまわない、キャラクターの体だけが枠内に収まればいい、という考え方です。


4. キャラクターの上だけクリックを受ける

透明なウィンドウは、見た目は透明でもクリックを吸い込みます。800×800 のウィンドウなら、キャラクターの周りの広い範囲で VS Code が操作できなくなります。

Electron の setIgnoreMouseEvents(true, { forward: true }) を使うと、ウィンドウ全体のクリックを下に素通しさせられます。問題は、キャラクターの上ではクリックを受けたいということです。そこで、マウス位置のピクセルが透明かどうかを WebGL の readPixels で1点だけ読み、状態が変わったときだけ切り替えます。

// adapters/live2d.js(抜粋)
// PIXI.Application は preserveDrawingBuffer: true で作っておく
hitTest(x, y) {
  if (!this.model) return false;
  if (x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight) return false;
  const r = this.app.renderer.resolution;
  const gl = this.app.renderer.gl;
  this.app.renderer.renderTexture.bind(null);   // 画面のフレームバッファを読む
  gl.readPixels(Math.floor(x * r), Math.floor(this.app.view.height - 1 - y * r), 1, 1,
    gl.RGBA, gl.UNSIGNED_BYTE, this._px);
  return this._px[3] > 16;                       // アルファが少しでもあれば「体の上」
}
// app.js(抜粋)
function updateHit(x, y) {
  const settingsOpen = settingsModal.style.display === 'block';
  const hit = isDragging || settingsOpen || mascot.hitTest(x, y);
  if (hit === !ignoring) return;            // 変化がなければ何もしない
  ignoring = !hit;
  ipcRenderer.send('set-ignore-mouse', ignoring);
}

ドラッグ中と設定画面を開いている間は、常にクリックを受けるようにしています。そうしないと、ドラッグの途中で透過に切り替わって手を離したことになったり、設定画面が操作できなくなったりします。

視線追従もウィンドウの外で効かせる

マウスの位置は、メインプロセスで screen.getCursorScreenPoint() を約30fpsで取得し、ウィンドウ相対の座標にしてレンダラーへ送っています。pixi-live2d-display の視線追従は、自分のキャンバス上でのポインター移動にしか反応しないので、そのままだとウィンドウの外にあるカーソルは目で追いません。

// src/main/main.js(抜粋)
cursorTimer = setInterval(() => {
  const p = screen.getCursorScreenPoint();
  const b = mainWindow.getContentBounds();
  const x = p.x - b.x, y = p.y - b.y;
  if (x === lastX && y === lastY) return;   // 動いていなければ送らない
  lastX = x; lastY = y;
  mainWindow.webContents.send('cursor', { x, y });
}, 33);

同じ座標をヒット判定にも使っています。forward: true の転送イベントが届かない環境でも、この仕組みだけで透過の切り替えができます。これは Tauri の set_ignore_cursor_events に forward 相当のオプションがないことを見越した作りでもあります。


5. Live2Dの表示:Cubism Core 6の罠

描画には PixiJS 6.5.9 と pixi-live2d-display 0.4.0 を使っています。最初は CDN から読み込んでいましたが、オフラインでも動くようにすべてローカル読み込みに切り替えました。

ここで最初の大きな地雷を踏みました。Live2D 公式の Cubism SDK for Web から最新の Cubism Core を取ってきて置いたところ、キャラクターが表示されず、次のエラーで止まりました。

[CSM][I]Live2D Cubism Core version: 06.00.0001 (100663297)
Uncaught TypeError: Cannot read properties of undefined (reading '0') (cubism4.min.js:1)

pixi-live2d-display 0.4.0(2022年)は、描画順の取得に drawables.renderOrders を使っています。Cubism Core 6 系ではこの形の API がなくなっていて、undefined の0番目を読もうとして落ちていました。

公式 CDN(https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js)で配布されている Core は 5.1 系で、こちらなら問題なく動きます。

[CSM][I]Live2D Cubism Core version: 05.01.0000 (83951616)

pixi-live2d-display は 0.4.0 から更新が止まっているので、Core の更新でまた壊れる可能性があります。当面は Core を 5.1 系に固定し、将来は後継のフォークか公式の Cubism Web Framework への乗り換えを検討します。アダプタに分けてあるので、乗り換えても直すのは live2d.js だけです。

Idleグループのないモデル

同梱モデルには、Live2D 公式サンプルの「ハル」を2体使っています。標準版と受付版です。

受付版の model3.json は、全モーションが名前のない1つのグループ("")に入っていて、Idle グループがありません。pixi-live2d-display は Idle グループを待機モーションとして自動再生するので、このままだとモーションが終わるたびに固まってしまいます。

そこで、ファイル名に idle を含むモーションを探して、Idle グループとして登録し直しました。

// adapters/live2d.js(抜粋)
_ensureIdleGroup() {
  const mm = this.model.internalModel.motionManager;
  const idleName = mm.groups.idle;                 // 既定は "Idle"
  if (mm.definitions[idleName]?.length) return;
  const idleDefs = [];
  for (const group of Object.keys(mm.definitions)) {
    for (const def of mm.definitions[group] || []) {
      if (/idle/i.test(def.File || def.file || '')) idleDefs.push(def);
    }
  }
  if (!idleDefs.length) return;
  mm.definitions[idleName] = idleDefs;
  mm.motionGroups[idleName] = [];                  // 読み込み済みモーションの入れ物
}

モデルごとの大きさの違い

最初は model.scale.set(0.12) と倍率で決めていましたが、元のキャンバスサイズはモデルごとに大きく違うので、モデルを替えると巨大になったり豆粒になったりします。表示したい高さ(px)を指定して、倍率を逆算する方式に変えました。

setHeight(px) {
  this.model.scale.set(px / this.naturalH);   // naturalH は scale=1 のときの高さ
}

6. 声・字幕・しぐさ:ボイスパック

最初の版で、ダブルクリックすると吹き出しには「こんにちは!」と出ているのに、声は「占ってあげようか?」としゃべる、という現象が起きました。

原因は、モデルの model3.json の中でモーションに音声ファイルが紐づいていて、pixi-live2d-display がモーションの再生と同時にその音声も自動で鳴らしていたことです。吹き出しの文字とはまったく無関係に鳴っていました。

そこで、声をモデルから切り離し、マスコット側で「ボイスパック」として持つことにしました。

{
  "name": "Haru",
  "credit": "Live2D Inc. sample data",
  "voices": [
    { "file": "../../haru/sounds/haru_normal_01.wav", "text": "お疲れ様です", "motion": "haru_normal_01", "on": ["idle", "greet"] },
    { "file": "../../haru/sounds/haru_normal_03.wav", "text": "えっ、びっくりしたー", "motion": "haru_normal_03", "on": ["click"] },
    { "file": "../../haru/sounds/haru_normal_09.wav", "text": "今日は何食べようかな", "motion": "haru_normal_09", "on": ["idle"] }
  ]
}
  • file: 音声ファイル(voices.json からの相対パス)
  • text: 吹き出しに出す字幕
  • motion: 合わせるしぐさ(モーションファイル名の部分一致。なければランダム)
  • on: 使う場面(クリック時の反応か、独り言か)

モデル内蔵の音声は PIXI.live2d.config.sound = false で止め、声はすべてボイスパックから鳴らします。こうすると、どのモデルにも同じボイスパックを組み合わせられて、声と字幕が必ず一致します。標準版ハルの声を、足まで描かれている受付版ハルにしゃべらせる、ということもできます。

音声の文字起こしは、公式に台本が見つからなかったので、自作の Text Extractor(Whisper STT)に wav を1本ずつ流して起こしました。

文字起こししてみると、声は「触られたときの反応」(「何かついてますか?」「わぁ、なにするんですかー」)と「独り言」(「今日は何食べようかな」)にはっきり分かれていました。それで on で場面を分けています。これがないと、放っておいたら急に「触りたいんですか?」と言い出します。

on には好きな名前を付けられるので、VS Code 連携では "on": ["save"] や "on": ["commit"] の声を用意すれば、そのイベントでしゃべらせられます。

Web Audioで再生して、音量から口パクを作る

最初は new Audio() で再生し、再生中は口をランダムに開閉させていました。これを Web Audio API での再生に変えて、AnalyserNode で音量(RMS)を測り、その値で口の開きを決めるようにしました。

// app.js(抜粋)
async function playAudioData(data, token) {
  audioCtx ||= new AudioContext();
  const buffer = await audioCtx.decodeAudioData(toArrayBuffer(data));
  if (token !== playToken) return;                 // 後から来た再生要求を優先

  // 音源ごとの音量差を吸収:一番大きい区間の音量で正規化する
  const ch = buffer.getChannelData(0);
  const WIN = 1024;
  let peak = 0;
  for (let i = 0; i < ch.length; i += WIN) {
    const end = Math.min(ch.length, i + WIN);
    let s = 0;
    for (let j = i; j < end; j++) s += ch[j] * ch[j];
    peak = Math.max(peak, Math.sqrt(s / (end - i)));
  }
  const gain = peak > 0 ? 1 / peak : 1;

  const src = audioCtx.createBufferSource();
  src.buffer = buffer;
  const analyser = audioCtx.createAnalyser();
  analyser.fftSize = 1024;
  src.connect(analyser);
  analyser.connect(audioCtx.destination);

  const wave = new Float32Array(analyser.fftSize);
  const tick = () => {
    analyser.getFloatTimeDomainData(wave);
    let sum = 0;
    for (let i = 0; i < wave.length; i++) sum += wave[i] * wave[i];
    const level = Math.sqrt(sum / wave.length) * gain;             // 0〜1
    const target = level < 0.12 ? 0 : Math.min(1, level * 1.2);    // 小さい音は閉じる
    mouthLevel += (target - mouthLevel) * 0.5;                     // なめらかに
    mascot.setMouth(mouthLevel);
    lipRAF = requestAnimationFrame(tick);
  };
  src.start();
  tick();
}

正規化を入れる前は、VOICEVOX の声では口が動くのに、ハルの wav では口がほとんど動きませんでした。VOICEVOX の出力は音量がそろっていて大きめですが、サンプルの wav はそれより小さいので、同じ計算式だと口が開かなかったのです。再生前に全体を走査して一番大きい区間の音量を基準にすることで、音源に関係なく同じくらい口が開くようになりました。

wav は fs でバイト列として読み込み、decodeAudioData に渡しています。file:// の URL を <audio> 要素で読んで createMediaElementSource に通すと、オリジンの扱いによっては解析結果が無音になる可能性があるので、それを避けています。

口の開きは、Live2D のモデル更新直前(beforeModelUpdate)に LipSync グループのパラメーターへ書き込みます。モーションの値を上書きする形になります。

// adapters/live2d.js(抜粋)
_applyMouth() {
  if (this.mouth == null || !this.model) return;
  const im = this.model.internalModel;
  for (const id of im.lipSyncIds || []) {          // PARAM_MOUTH_OPEN_Y など
    im.coreModel.setParameterValueById(id, this.mouth);
  }
}

VOICEVOX連携はメインプロセスから

時報や挨拶のように、文言がその場で変わるセリフはボイスパックでは用意できません。そこで読み上げに VOICEVOX を使えるようにしました。ローカルで VOICEVOX Engine(127.0.0.1:50021)が起動していれば、それを使います。

画面側(file:// のページ)から直接 fetch すると、オリジンが null 扱いになって CORS で弾かれる可能性があります。そこで、メインプロセス(Node.js)から叩いて、wav のバイト列だけを IPC でレンダラーに返す形にしました。Node からの通信には CORS がありません。

// src/main/main.js(抜粋)
ipcMain.handle('voicevox-synth', async (event, { text, speaker, speed = 1 }) => {
  try {
    const q = await fetch(
      `${VOICEVOX}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
      { method: 'POST', signal: AbortSignal.timeout(10000) }
    );
    if (!q.ok) throw new Error('audio_query ' + q.status);
    const query = await q.json();
    query.speedScale = speed;

    const s = await fetch(`${VOICEVOX}/synthesis?speaker=${speaker}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(query),
      signal: AbortSignal.timeout(30000)
    });
    if (!s.ok) throw new Error('synthesis ' + s.status);
    return new Uint8Array(await s.arrayBuffer());
  } catch (e) {
    return voicevoxDown(e);   // 例外を投げずに null を返し、OS音声に切り替えさせる
  }
});

VOICEVOX が起動していなければ、OS 標準の読み上げ(speechSynthesis)に自動で切り替えます。返ってきた wav は、ボイスパックと同じ playAudioData で再生されるので、VOICEVOX の声でも口パクが動きます。


7. モデルの取り込みとライブラリ

最初はモデルの model3.json の場所を覚えて直接読むだけでした。これだと、元のファイルを動かすと読めなくなります。

Electron ならファイルシステムを直接扱えるので、IndexedDB などは使わず、アプリ専用のフォルダ(%APPDATA%\Live2D Desktop Mascot\models\)をライブラリにしました。中身をエクスプローラーで見られるし、バックアップもフォルダをコピーするだけです。

ZIP をキャラクターの上にドロップすると、中から .model3.json を探して、そのフォルダ以下だけを展開します。公式サンプルの ZIP のように、runtime/ の外に PSD などが入っていても、それは取り込みません。

// src/main/library.js(抜粋)
function importZip(zipPath) {
  const zip = new AdmZip(zipPath);
  const entries = zip.getEntries();
  const modelEntry = entries.find((e) => !e.isDirectory && MODEL_EXT.test(e.entryName) && !e.entryName.startsWith('__MACOSX'));
  if (!modelEntry) throw new Error('ZIPの中に .model3.json が見つかりません');

  const prefix = modelEntry.entryName.includes('/')
    ? modelEntry.entryName.slice(0, modelEntry.entryName.lastIndexOf('/') + 1)
    : '';
  const name = uniqueName(path.basename(modelEntry.entryName).replace(MODEL_EXT, ''));
  const dest = path.join(libraryDir(), name);

  for (const e of entries) {
    if (e.isDirectory || !e.entryName.startsWith(prefix)) continue;
    const rel = e.entryName.slice(prefix.length);
    const out = path.resolve(dest, rel);
    if (!out.startsWith(dest + path.sep)) continue;   // パストラバーサル(Zip Slip)対策
    fs.mkdirSync(path.dirname(out), { recursive: true });
    fs.writeFileSync(out, e.getData());
  }
  return name;
}

../ を含むエントリ名で展開先の外に書き込まれる Zip Slip 対策として、展開先のパスが必ずライブラリの中に収まるかを確認しています。


8. 配布:electron-builder

ビルドには electron-builder を使いました。設定は package.json に書くだけです。

"build": {
  "appId": "com.fixtan.live2d-desktop-mascot",
  "productName": "Live2D Desktop Mascot",
  "files": ["src//*", "assets//*", "vendor//*", "package.json"],
  "win": { "target": ["nsis", "portable"], "icon": "build/icon.ico" },
  "nsis": {
    "oneClick": false,
    "allowToChangeInstallationDirectory": true,
    "artifactName": "live2d-desktop-mascot-${version}-setup.${ext}"
  },
  "portable": { "artifactName": "live2d-desktop-mascot-${version}-portable.${ext}" },
  "mac": {
    "target": [
      { "target": "dmg", "arch": ["arm64", "x64"] },
      { "target": "zip", "arch": ["arm64", "x64"] }
    ],
    "icon": "build/icon.png",
    "identity": null,
    "artifactName": "live2d-desktop-mascot-${version}-mac-${arch}.${ext}"
  }
}

npm run dist:win で Windows 用のインストーラーとポータブル版(それぞれ約81MB)、Mac 上で npm run dist:mac を実行すると、Apple Silicon 用と Intel 用の dmg(約105MB・110MB)ができます。Mac 版は Mac 上でしかビルドできないので、Mac でリポジトリを clone してビルドしました。

artifactName を指定していないと、ファイル名に productName のスペースがそのまま入ります。GitHub の Release に上げると、スペースはドットに置き換えられて Live2D.Desktop.Mascot.Setup.1.0.0.exe のような名前になるので、最初からスペースなしの名前にしておくほうがきれいです。

署名していないアプリの扱い

コード署名には証明書の契約が必要です。無料・非営利のツールなので署名はせず、README に警告が出たときの開き方を書いておく形にしました。

  • Windows: SmartScreen の「Windows によって PC が保護されました」→「詳細情報」→「実行」
  • macOS: 「システム設定」→「プライバシーとセキュリティ」→「このまま開く」。「壊れているため開けません」と出る場合は xattr -cr で隔離属性を外す

タスクトレイ

クリックが下に抜ける作りなので、キャラクターが画面外に出てしまったりすると、操作する手段がなくなります。そこで、タスクバーからは外して(skipTaskbar: true)、タスクトレイに「表示/非表示・位置をリセット・設定・クレジット・終了」を置きました。多重起動は app.requestSingleInstanceLock() で防いでいます。

ライセンスの扱い

同梱物のうち、Live2D Cubism Core とサンプルモデル「ハル」は Live2D 社の規約に従います。リポジトリにはこれらを含めず(.gitignore で除外)、各自でダウンロードして配置してもらう形にしました。配布する実行ファイルには同梱し、アプリ内の「クレジット」画面に Live2D・VOICEVOX・各 OSS のライセンス表記を出しています。アプリ自体のソースコードは MIT License です。


9. macOSの落とし穴

Windows で作ったものを Mac で動かすと、起動はするものの、Mac 特有の問題がいくつか出ました。

Dockアイコンが一瞬出て消える

常駐型のアプリなので、最初は app.dock.hide() で Dock アイコンを消していました。ところが、メニューバーのアイコンがノッチの裏に隠れて見えず、隠したキャラクターを戻す手段がなくなりました。

そこで Dock アイコンを残すことにして app.dock.hide() を消したのですが、それでも起動直後に一瞬アイコンが出て消えます。

原因は、全デスクトップ(Space)とフルスクリーンアプリの上にも表示するための次の1行でした。

mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });

visibleOnFullScreen: true を指定すると、Electron が内部でアプリを「Dock に出ない常駐アプリ」扱いに切り替えます。これを止めるオプションが skipTransformProcessType です。

mainWindow.setVisibleOnAllWorkspaces(true, {
  visibleOnFullScreen: true,
  skipTransformProcessType: true   // これが無いと Dock アイコンが消える
});

さらに、Dock アイコンをクリックしたときや、アプリをもう一度起動したときに、隠れているキャラクターを戻すようにしました。

const bringBack = () => {
  if (!mainWindow) return;
  if (mainWindow.isMinimized()) mainWindow.restore();
  mainWindow.show();
};
app.on('second-instance', bringBack);
app.on('activate', bringBack);     // macOS: Dock アイコンのクリック

透明ウィンドウを最小化すると見えなくなる

透明なウィンドウを最小化すると、Dock に入るサムネイルまで透明になり、どこに行ったのかわからなくなります。マスコットは最小化するものではないので、minimizable: false で最小化自体を無効にしました。

メニューバーのアイコンの大きさ

Windows のタスクトレイ用に作った 32px のアイコンは、Mac のメニューバーでは大きすぎます。Mac のときだけ 18px に縮めています。

let icon = nativeImage.createFromPath(path.join(APP_ROOT, 'assets/tray.png'));
if (process.platform === 'darwin') icon = icon.resize({ width: 18, height: 18 });

Macから持ってきたdmgが0バイト

Release に dmg を添付しようとして、Mac から Windows にファイルを移したところ、0.00MB の ._live2d-desktop-mascot-1.1.0-mac-arm64.dmg をアップロードしていました。これは Mac のファイルを共有フォルダや USB メモリにコピーしたときに一緒に作られる、AppleDouble という付属情報ファイルです。中身はほぼ空です。ファイル名の頭の ._ とサイズを見れば見分けられます。

タグとリリースのずれ

npm version minor で v1.1.0 のタグを付けたあとに、Mac 対応の修正を3つコミットしてからビルドしていました。Release のページに「3 commits to main since this release」と表示され、配布したバイナリと Source code の中身が食い違っていました。

git tag -f v1.1.0
git push -f origin v1.1.0

でタグを最新のコミットに付け直して揃えました。次からは「修正を全部コミット → npm version → ビルド → 添付」の順番を守ります。


10. Electron初挑戦の感想

Tauri と比べると、Electron の一番の利点は「どの OS でも中身が同じ Chromium」であることでした。Tauri は各 OS 標準の WebView(Windows は WebView2、macOS は WKWebView、Linux は WebKitGTK)を使うので、WebGL と透明ウィンドウが中心のこのアプリでは、エンジンごとの動作確認が必要になります。今回、Windows で動いたものが Mac でも描画まわりはそのまま動いたのは、Electron だからです。

逆に弱点はサイズとメモリです。配布物は 80〜110MB あり、Tauri なら 10MB 前後に収まるはずです。常駐させるアプリとしては重いのも確かなので、重さが気になってきたら Tauri 版を作るつもりです。そのときに書き直すのはメインプロセス(ウィンドウ制御・位置追跡・通信)だけで、描画側はアダプタ構成のおかげでほぼそのまま持っていけます。

VS Code 自体が Electron で作られていて、拡張機能も VS Code の中の Node.js で動いています。マスコット本体と VS Code と拡張機能が、全部同じ土台の上に乗っているという構図も面白いところです。


まとめ

  • VS Code 拡張は決まった枠の中にしか描画できないので、キャラクターを浮かせるには単体のデスクトップアプリが必要。拡張機能はイベントを送るだけの役割に分ける。
  • VS Code の位置は、PowerShell を常駐させて DwmGetWindowAttribute で取得し、screenToDipRect で DIP に換算する。押し戻しの判定はウィンドウ枠ではなく、キャラクターの描画範囲で行う。
  • setIgnoreMouseEvents と WebGL の readPixels を組み合わせると、キャラクターの不透明なピクセルの上だけクリックを受けられる。
  • pixi-live2d-display 0.4.0 は Cubism Core 6 系で動かない。Core は CDN の 5.1 系に固定する。
  • 声はモデルから切り離してボイスパックにし、声・字幕・しぐさ・場面をまとめて持つ。口パクは Web Audio で音量を測り、音源ごとに正規化する。
  • VOICEVOX はメインプロセスから叩けば CORS を気にしなくていい。つながらなければ OS 標準の読み上げに切り替える。
  • electron-builder で Windows(インストーラー/ポータブル)と macOS(arm64/x64)の配布物が作れる。macOS では skipTransformProcessType、最小化の無効化、メニューバーアイコンの大きさに注意する。

次は VS Code 拡張を作って WebSocket でつなぎ、ファイルの保存やエラー数、ビルドの成否にマスコットが反応するようにします。