iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💻

[elecxzy] How to Intercept the OS Close Button Event in Electron

に公開

Implementing "Save Changes?" in Electron - The elecxzy Implementation for Checking Renderer State and Exiting Safely

When building a Single Page Application (SPA) text editor like elecxzy (currently under development) with Electron, a challenge you'll inevitably face is "controlling the behavior when a user tries to close the window via the close button while there is unsaved data."

In Electron, the main process controls the window's close button (the 'X' button), but the state of whether a buffer is "dirty" (unsaved) exists within the state of the renderer process (React, etc.).

In this article, I'll summarize the "standard approach" for crossing this process barrier and implementing a safe confirmation flow that even works with OS-level exit operations.


1. The Challenge: The Main Process Doesn't Know if "Saving is Required"

By default, in Electron, when a window's close event occurs, the window is destroyed immediately. However, the renderer process is what manages the buffer state (such as a modified flag).

Instead of simply trying to close the window from the main process side, you need a workflow that "delegates the confirmation to the renderer."

2. Implementation Overview

The implementation consists of the following three steps. The key is to temporarily cancel the OS event and wait for "permission" from the renderer.

  1. Main Process: Intercept the close event, pause the exit (preventDefault), and query the renderer.
  2. Renderer Process: Check the buffer state and, if necessary, show a dialog (or minibuffer) to the user.
  3. Renderer -> Main: Once the decision to save or discard has been made, notify the main process that "it's okay to close (resume)."

3. Implementation Code

① Main Process: Intercepting the Exit (e.g., main/index.ts)

In the main process, we use a flag to manage whether the exit has been "confirmed."

// Flag indicating whether the application is ready to quit
let isReadyToQuit = false;

// 3. Receive "permission to quit" from the renderer
ipcMain.on('confirm-quit', () => {
    isReadyToQuit = true;
    // Calling close() again here will trigger the event listener below once more
    if (mainWindow) mainWindow.close();
});

mainWindow.on('close', (e) => {
    // 1. If not ready to quit yet
    if (!isReadyToQuit) {
        // Prevent the window from closing immediately
        e.preventDefault();
        
        // 2. Query the renderer: "I want to close, is that okay?"
        mainWindow?.webContents.send('app-close-request');
        return;
    }
    
    // 4. If isReadyToQuit is true, it passes through here and the normal exit process occurs
});

② Preload: Secure Communication Path (e.g., preload/index.ts)

We use contextBridge to enable the renderer to communicate with the main process.

contextBridge.exposeInMainWorld('electronAPI', {
    // Subscribe to queries from the main process
    onAppCloseRequest: (callback: () => void) => {
        ipcRenderer.on('app-close-request', callback);
        return () => ipcRenderer.removeListener('app-close-request', callback);
    },
    // Notify the main process that confirmation is complete
    confirmQuit: () => ipcRenderer.send('confirm-quit')
});

③ Renderer Process: Executing the Confirmation Flow (e.g., App.tsx)

Monitor requests from the main process in a React component or similar, and reuse your existing "exit confirmation logic."

useEffect(() => {
    // Monitor the "Is it okay to close?" request from the main process
    if (window.electronAPI.onAppCloseRequest) {
        const unsubscribe = window.electronAPI.onAppCloseRequest(() => {
            // Start the app-specific exit confirmation process here
            handleQuitWithConfirmation(); 
        });
        return () => unsubscribe();
    }
}, [handleQuitWithConfirmation]);

// Exit confirmation logic
const handleQuitWithConfirmation = () => {
    // If there are no unsaved buffers
    if (modifiedBuffers.length === 0) {
        // Permit immediate exit and notify the main process
        window.electronAPI.confirmQuit();
        return;
    }

    // If there are unsaved changes:
    // Prompt the user via a minibuffer, etc. (Save file ...? y/n)
    // Call confirmQuit() after all saving processes are completed
    // * Internally, check all buffers using recursive list traversal or Promises
    promptSaveParams().then((result) => {
         if (result === 'saved' || result === 'discarded') {
             window.electronAPI.confirmQuit();
         }
         // Do nothing if cancelled (the window remains open)
    });
};

4. Key Point: Unified UX

The biggest advantage of this approach is that both the "OS close button" and "in-app shortcuts (e.g., C-x C-c)" go through the exact same function (logic).

In elecxzy, the Emacs-like editor I'm currently developing, I was able to show the familiar Emacs minibuffer confirmation interface—Save file ...? (y, n, !, .)—even for OS window close operations, rather than a standard OS dialog. This provides a consistent user experience regardless of how the application is closed.

Summary

  1. Stop the main process exit using event.preventDefault().
  2. Use IPC to check the renderer's state (buffer dirty flags).
  3. Once confirmed, notify the main process via IPC and call close() again.

By following this "standard pattern," even editors built as SPAs (Single Page Applications) can achieve the safe exit behavior expected of a desktop application.

Discussion