【elecxzy】ElectronでOSの「×ボタン」による終了イベントをインターセプトする方法
Electronで「保存しますか?」を実装する - レンダラーの状態を確認して安全に終了するelecxzyの実装
開発中のelecxzyのようなSPA(Single Page Application)のテキストエディタをElectronで作るとき、避けて通れないのが「未保存のデータがある状態で×ボタンからウィンドウを閉じようとしたときの制御」です。
Electronでは、ウィンドウの閉じボタン(×ボタン)はメインプロセスが扱いますが、バッファが「汚れている(dirty/未保存)」という状態はレンダラープロセス(React等)のステートにあります。
今回は、このプロセス間の壁を越えて、OSの終了操作に対しても安全な確認フローを入れる定石をまとめます。

1. 課題:メインプロセスは「保存が必要か」を知らない
Electronのデフォルトの挙動では、ウィンドウのcloseイベントが発生するとそのままウィンドウが破棄されます。しかし、バッファの状態(dirtyフラグなど)を管理しているのはレンダラープロセスです。
そのため、メインプロセス側でそのまま閉じるのではなく、確認をレンダラーに任せる流れが必要になります。
2. 実装の全体像
次の3ステップで実装します。OSのイベントを一度キャンセルし、レンダラーからの許可を待つのがポイントです。
-
Mainプロセス:
closeイベントをインターセプトし、終了を止めて(preventDefault)レンダラーへ問い合わせる。 - Rendererプロセス: バッファの状態を確認し、必要ならユーザーにダイアログ(またはミニバッファ)を出す。
- Renderer -> Main: 保存や破棄の判断が終わったら、メインプロセスに「閉じてよい」と伝える。
3. 実装のコード
① メインプロセス:終了のインターセプト(electron/main/index.ts)
メインプロセスでは、フラグで「確認済みかどうか」を管理します。
// アプリケーション終了の準備が整ったかどうかのフラグ
let isReadyToQuit = false;
// 3. レンダラーからの「終了許可」を受け取る
ipcMain.on('confirm-quit', () => {
isReadyToQuit = true;
// ここで再度 close() を呼ぶと、下のイベントリスナーが再度発火する
if (mainWindow) mainWindow.close();
});
mainWindow.on('close', (e) => {
// 1. まだ準備ができていない場合
if (!isReadyToQuit) {
// 即座に閉じるのを阻止
e.preventDefault();
// 2. レンダラーに「閉じたいんだけど、大丈夫?」と問い合わせる
// (OSのシャットダウンなど app.quit() 経由のときは force: true を付ける)
mainWindow?.webContents.send('app-close-request', isAppQuitting ? { force: true } : undefined);
return;
}
// 4. isReadyToQuit が true なら、ウィンドウ状態を保存して通常の終了処理へ進む
windowState.save(mainWindow!, true);
});
② プリロード:安全な通信経路(electron/preload/index.ts)
contextBridgeを使い、レンダラーがメインプロセスと通信できるようにします。
contextBridge.exposeInMainWorld('electronAPI', {
// メインからの問い合わせを購読
onAppCloseRequest: (callback: (options?: { force?: boolean }) => void) => {
const subscription = (_event: any, options?: { force?: boolean }) => callback(options);
ipcRenderer.on('app-close-request', subscription);
return () => ipcRenderer.removeListener('app-close-request', subscription);
},
// 確認完了をメインに通知
confirmQuit: () => ipcRenderer.send('confirm-quit'),
});
③ レンダラープロセス:確認フローの実行(App.tsx、useBufferManager.ts)
Reactコンポーネントでメインプロセスからの要求を監視し、C-x C-cと同じ終了確認ロジックに流します。
// App.tsx
useEffect(() => {
// メインプロセスからの「閉じていい?」リクエストを監視
const unsubscribe = window.electronAPI.onAppCloseRequest((options) => {
// ワークスペースの保存確認を経て handleQuitWithConfirmation() へ進む
quitWithWorkspaceCheck(options);
});
return () => unsubscribe();
}, [quitWithWorkspaceCheck]);
// useBufferManager.ts:終了確認ロジック
const handleQuitWithConfirmation = useCallback(() => {
const modifiedBuffers = Object.entries(buffersRef.current)
.filter(([_, buf]) => buf.dirty && !buf.readOnly);
// 未保存のバッファがなければ、すぐにメインプロセスへ終了許可を通知
if (modifiedBuffers.length === 0) {
window.electronAPI.confirmQuit();
return;
}
// 未保存がある場合は、1バッファずつミニバッファで確認する
const checkNext = (index: number) => {
if (index >= modifiedBuffers.length) {
window.electronAPI.confirmQuit(); // 全バッファの確認が済んだら終了
return;
}
const [bufId, buf] = modifiedBuffers[index];
openMinibuffer(`Save file ${buf.filePath || buf.name}? (y: Yes, n: No, !: Save All, .: Save & Quit, q: Cancel) `, (val) => {
switch (val?.toLowerCase().trim()) {
case 'y': handleSave(bufId).then(ok => checkNext(ok ? index + 1 : index)); break;
case 'n': checkNext(index + 1); break;
// '!'(残りを全て保存)、'.'(保存してすぐ終了)は省略
case 'q': setEchoMessage('Quit cancelled'); break; // ウィンドウは開いたまま
default: checkNext(index); break;
}
}, { mode: 'generic', candidates: ['y', 'n', '!', '.', 'q'], singleKeyMode: true });
};
checkNext(0);
}, [/* ... */]);
4. この実装のポイント:UXの統一
このやり方の利点は、OSの×ボタンもアプリ内のショートカット(C-x C-c等)も、まったく同じ関数を通ることです。
Emacsっぽいエディタのelecxzyでは、OSのウィンドウを閉じる操作に対しても、OS標準のダイアログではなく、EmacsでおなじみのミニバッファによるSave file ...? (y, n, !, ., q)という確認を出せるようになりました。どの操作から終了しても同じ確認が出ます。
まとめ
-
event.preventDefault()でメイン側の終了を止める。 - IPCでレンダラーのステート(バッファの
dirtyフラグ)を確認する。 - 確認が終わったらIPCでメインに通知し、再び
close()を呼ぶ。
この定石に沿えば、SPAとして作ったエディタでも、デスクトップアプリとして期待される安全な終了挙動にできます。
Discussion