概要
個人用の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を徹底するかを決めた方が良い。