💻

【elecxzy】ElectronでOSの「×ボタン」による終了イベントをインターセプトする方法

に公開
2026/10/04

Electronで「保存しますか?」を実装する - レンダラーの状態を確認して安全に終了するelecxzyの実装

開発中のelecxzyのようなSPA(Single Page Application)のテキストエディタをElectronで作るとき、避けて通れないのが「未保存のデータがある状態で×ボタンからウィンドウを閉じようとしたときの制御」です。

Electronでは、ウィンドウの閉じボタン(×ボタン)はメインプロセスが扱いますが、バッファが「汚れている(dirty/未保存)」という状態はレンダラープロセス(React等)のステートにあります。

今回は、このプロセス間の壁を越えて、OSの終了操作に対しても安全な確認フローを入れる定石をまとめます。


1. 課題:メインプロセスは「保存が必要か」を知らない

Electronのデフォルトの挙動では、ウィンドウのcloseイベントが発生するとそのままウィンドウが破棄されます。しかし、バッファの状態(dirtyフラグなど)を管理しているのはレンダラープロセスです。

そのため、メインプロセス側でそのまま閉じるのではなく、確認をレンダラーに任せる流れが必要になります。

2. 実装の全体像

次の3ステップで実装します。OSのイベントを一度キャンセルし、レンダラーからの許可を待つのがポイントです。

  1. Mainプロセス: closeイベントをインターセプトし、終了を止めて(preventDefault)レンダラーへ問い合わせる。
  2. Rendererプロセス: バッファの状態を確認し、必要ならユーザーにダイアログ(またはミニバッファ)を出す。
  3. 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)という確認を出せるようになりました。どの操作から終了しても同じ確認が出ます。

まとめ

  1. event.preventDefault()でメイン側の終了を止める。
  2. IPCでレンダラーのステート(バッファのdirtyフラグ)を確認する。
  3. 確認が終わったらIPCでメインに通知し、再びclose()を呼ぶ。

この定石に沿えば、SPAとして作ったエディタでも、デスクトップアプリとして期待される安全な終了挙動にできます。

Discussion