zudo-paper

Electronアプリのショートカット設定UIとconfig.json共有の罠

Author: Takazudo | 作成: 2026/03/07

概要

個人用のElectronアプリで、エディタのプレビュー切り替えショートカット(デフォルト: Cmd+E)をユーザーがカスタマイズできるようにした。Obsidianのショートカット設定UIを参考にキャプチャUIを実装し、設定の永続化まで一通り作ったところで、ショートカットを変更するとアプリが起動しなくなるバグを踏んだ。原因はconfig.jsonを複数の処理が異なるスキーマで読み書きしていたこと。そのまとめ。

ショートカットキャプチャUI

Obsidianのショートカット設定画面を参考にした。UIとしてはシンプルで、ボタンをクリックすると「Press shortcut...」の状態になり、キーを押すとそのキーの組み合わせが記録される。

keydownイベントからショートカット文字列を組み立てる関数は以下のようになっている。

function keyEventToShortcut(e: KeyboardEvent): string | null {
  if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return null;
  const parts: string[] = [];
  if (e.metaKey || e.ctrlKey) parts.push("Mod");
  if (e.altKey) parts.push("Alt");
  if (e.shiftKey) parts.push("Shift");
  parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
  return parts.join("+");
}

修飾キーだけが押された場合はnullを返して無視する。Cmd+EならMod+E、Cmd+Shift+PならMod+Shift+Pのような文字列になる。

Modキーによるクロスプラットフォーム対応

Modはプラットフォーム非依存のキー表現で、macOSではCmd、WindowsではCtrlにマッピングされる。内部では常にModとして保存し、表示時にプラットフォームに応じた表記に変換する。

function formatShortcutForDisplay(shortcut: string): string {
  return shortcut.replace(/Mod/g, isMac ? "Cmd" : "Ctrl");
}

保存時はMod+E、表示時はmacOSならCmd+E、WindowsならCtrl+Eになる。こうしておくと設定ファイルがプラットフォーム間で共有できる。

ショートカットの適用: useRefの活用

ショートカットの適用にはuseRefを使っている。

const shortcutRef = useRef("Mod+E");
 
useEffect(() => {
  window.api.settings.get().then((saved) => {
    if (saved?.shortcuts?.toggleEditorPreview) {
      shortcutRef.current = saved.shortcuts.toggleEditorPreview;
    }
  });
}, []);
 
useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    const { modifiers, key: targetKey } = parseShortcut(shortcutRef.current);
    const modMatch =
      modifiers.includes("Mod") === (e.metaKey || e.ctrlKey) &&
      modifiers.includes("Alt") === e.altKey &&
      modifiers.includes("Shift") === e.shiftKey;
    if (modMatch && e.key.toUpperCase() === targetKey.toUpperCase()) {
      e.preventDefault();
      togglePreview();
    }
  };
  window.addEventListener("keydown", handler);
  return () => window.removeEventListener("keydown", handler);
}, []);

useRefを使う理由は、ショートカットの値が変わってもkeydownのイベントリスナーを再登録しなくて済むから。useStateで管理すると、値が変わるたびにuseEffectが再実行されてリスナーの登録・解除が走る。useRefなら.currentを読むだけなので、リスナーは一度登録すれば良い。

config.json共有の問題

ここからが本題。アプリの設定ファイルとして~/.config/myapp/config.jsonを使っていた。このファイルに対して、2つの異なる処理が書き込みをしていた。

1つ目はプロジェクトパスの保存。アプリが開くプロジェクトのパスを記録する処理。

{ "projectPath": "/path/to/project" }

2つ目は設定画面からの保存。ショートカットやエディタ設定などをまとめて保存する処理。

{
  "general": { "...": "..." },
  "editor": { "...": "..." },
  "shortcuts": { "toggleEditorPreview": "Mod+E" }
}

設定保存のIPCハンドラはこうなっていた。

ipcMain.handle("settings:save", (_event, settings) => {
  const validated = {
    general: {
      /* ... */
    },
    editor: {
      /* ... */
    },
    shortcuts: {
      toggleEditorPreview: settings.shortcuts?.toggleEditorPreview || "Mod+E",
    },
  };
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(validated, null, 2));
});

validatedオブジェクトにはprojectPathが含まれていない。writeFileSyncでファイル全体を上書きするので、設定を保存した瞬間にprojectPathキーが消える。

次回アプリを起動すると、getProjectRoot()config.projectPathを読みに行くがキーが存在しない。パッケージ版のアプリではプロジェクトパスがconfig.jsonに依存しているので、アプリが正常に起動できなくなる。

「ショートカットを変更したらアプリが壊れた」という現象だが、原因はショートカットとは関係なく、設定保存がprojectPathを消していたということになる。

修正1: 既存キーの保持

まず、設定保存時に既存の内容を読み込んで、projectPathを保持するようにした。

let existing = {};
try {
  if (fs.existsSync(CONFIG_PATH)) {
    existing = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
  }
} catch {}
 
const toWrite = { ...validated };
if (existing.projectPath) {
  toWrite.projectPath = existing.projectPath;
}
fs.writeFileSync(CONFIG_PATH, JSON.stringify(toWrite, null, 2));

read-modify-writeパターン。書き込む前に既存の内容を読み、必要なキーを保持してからファイルに書く。

修正2: 読み込み側のフォールバック

読み込み側にもフォールバックを追加した。projectPathが消えていた場合にgeneral.projectRootからも読めるようにする。

if (config.projectPath && fs.existsSync(config.projectPath)) {
  return config.projectPath;
}
if (config.general?.projectRoot && fs.existsSync(config.general.projectRoot)) {
  return config.general.projectRoot;
}

古いキー名でも新しいキー名でも読めるようにしておくことで、過渡期のconfig.jsonでもアプリが動く。

教訓

この問題の根本的な原因は「1つのファイルを異なるスキーマで読み書きしていた」ということ。

対策としては以下。

  • 同じファイルに対する書き込みは常にread-modify-writeパターンを使う。ファイル全体を上書きする前に、既存の内容を読んで必要なキーを保持する
  • 用途が明確に異なるデータはファイルを分ける。例えばプロジェクト状態はstate.json、ユーザー設定はsettings.jsonのように分離する
  • 1つのファイルを複数のスキーマで扱わない。「このキーはあっちの処理が管理している」みたいな暗黙の依存関係は、遅かれ早かれ壊れる

キャプチャUI自体はシンプルに作れる。keydownイベントから修飾キーを組み立ててModで抽象化するだけ。一方、設定の永続化は見た目よりも壊れやすい。特に個人開発では「とりあえず1つのファイルにまとめておく」をやりがちだが、書き込み処理が2つ以上になった時点でファイルを分割するか、read-modify-writeを徹底するかを決めた方が良い。