Search code examples
javascriptreactjselectronsavefiledialogsave-as

User save progress to file in electron/react app


I'm creating an Electron app where the user creates and works on a "project." I'm trying to implement a "Save" button so the user can save their progress. I think there are two steps to this functionality: (i) get the file name/location from the user and then (ii) save the data to file.

For step (i) I have implemented

const get_file_name = async () => {
    try {
        return await window.showSaveFilePicker({
            types: [{
                     accept: { "application/octet-stream": [".custom"], }
                    }],
                });    
            // catches when the user hits cancel
            } catch(err) {}
        }
    }
}

However, I get the message dyn.age80g55r is not a valid allowedFileType because it doesn't conform to UTTypeItem because I use custom as my file extension. Is there a way to use a custom file extension? I would like it to be specific to the app that I am creating.

For step (ii) I implemented:

get_file_name().then((file) => {
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.downlaod = file.name;
    link.href = url;
    link.click();
});

However, this just opens a new page showing the contents of blob rather than downloading the new file. Does anyone know how I can download blob into the file?

More generally, is there a better way to allow the user to save their progress to file?


Solution

  • Your current approach is based on the mindset on implementation within the render side / process only.

    Electron has a multi-process architecture. Read about the Process Model for more information.

    Utilising this multi-process model, we can move the file dialog selection window and the download (referred to in the below code as the save function) to the main process.

    If you have the ability to, keep your render process(es) as simple as possible. Use them only for rendering the UI and UI interaction. IE: No heavy lifting.

    The minimum reproducible example below does the following:

    • Displays the intended file path and allows for manual or native file dialog selection.
    • Displays a textarea field for entering example data.
    • Displays a Save button to save the example data to the selected file extension (.txt file in this case).

    You mentioned that you also wish to use a custom file extension. The below code demonstrates its use. You can change your "custom" file extension to anything you wish, even a non-standard file extension that only your application will recognise and understand.

    Whilst your requirements may be very different to the example code below, such as:

    • Knowing the default (last used) path of the users file on application start.
    • Needing to save .json data to a file instead of plain text.
    • Saving to a file triggered by events instead of a Save button.

    All going well, this should be easy enough for you to implement as only you know the requirements of your application.


    main.js (main process)

    // Import required electron modules
    const electronApp = require('electron').app;
    const electronBrowserWindow = require('electron').BrowserWindow;
    const electronDialog = require('electron').dialog;
    const electronIpcMain = require('electron').ipcMain;
    
    // Import required Node modules
    const nodeFs = require('fs');
    const nodeFsPromises = require('node:fs/promises');
    const nodePath = require('path');
    
    // Prevent garbage collection
    let window;
    
    function createWindow() {
        const window = new electronBrowserWindow({
            x: 0,
            y: 0,
            width: 800,
            height: 600,
            show: false,
            webPreferences: {
                nodeIntegration: false,
                contextIsolation: true,
                sandbox: true,
                preload: nodePath.join(__dirname, 'preload.js')
            }
        });
    
        window.loadFile(nodePath.join(__dirname, 'index.html'))
            // Send path to render process to display in the UI
            .then(() => { window.webContents.send('populatePath', electronApp.getPath('documents')); })
            .then(() => { window.show(); });
    
        return window;
    }
    
    electronApp.on('ready', () => {
        window = createWindow();
    });
    
    electronApp.on('window-all-closed', () => {
        if (process.platform !== 'darwin') {
            electronApp.quit();
        }
    });
    
    electronApp.on('activate', () => {
        if (electronBrowserWindow.getAllWindows().length === 0) {
            createWindow();
        }
    });
    
    // ---
    
    electronIpcMain.handle('openPathDialog', (event, path) => {
        let options = {
            defaultPath: path,
            buttonLabel: 'Select',
            filters: [{
                name: 'My Custom Extension',
                extensions: ['txt']
            }]
        };
    
        // Return the path to display in the UI
        return openSaveDialog(window, options)
            .then((result) => {
                // Returns "undefined" if dialog is cancelled
                if (result.canceled) { return }
    
                return path = result.filePaths[0];
            })
    });
    
    electronIpcMain.on('saveData', (event, object) => {
        // Check the path (file) exists
        nodeFsPromises.readFile(object.path, {encoding: 'utf8'})
            .then(() => {
                // Save the data to the file
                nodeFs.writeFileSync(object.path, object.data);
            })
            // Show invalid file path error via main process dialog box
            .catch(() => {
                let options = {
                    type: 'warning',
                    title: 'Invalid Path',
                    message: 'Please select a valid path before saving.'
                };
    
                openMessageBoxSync(window, options);
            })
    })
    
    function openSaveDialog(parentWindow, options) {
        // Return selected path back to the UI
        return electronDialog.showOpenDialog(parentWindow, options)
            .then((result) => { if (result) { return result; } })
            .catch((error) => { console.error('System file dialog error: ' + error); });
    }
    
    function openMessageBoxSync(parentWindow, options) {
        return electronDialog.showMessageBoxSync(parentWindow, options);
    }
    

    preload.js (main process)

    // Import the necessary Electron modules
    const contextBridge = require('electron').contextBridge;
    const ipcRenderer = require('electron').ipcRenderer;
    
    // White-listed channels
    const ipc = {
        'channels': {
            // From render to main
            'send': [
                'saveData'
            ],
            // From main to render
            'receive': [
                'populatePath'
            ],
            // From main to render (once)
            'receiveOnce': [],
            // From render to main and back again
            'sendReceive': [
                'openPathDialog'
            ]
        }
    };
    
    // Exposed protected methods in the render process
    contextBridge.exposeInMainWorld(
        // Allowed 'ipcRenderer' methods
        'ipcRenderer', {
            // From render to main
            send: (channel, args) => {
                if (ipc.channels.send.includes(channel)) {
                    ipcRenderer.send(channel, args);
                }
            },
            // From main to render
            receive: (channel, listener) => {
                if (ipc.channels.receive.includes(channel)) {
                    // Deliberately strip event as it includes `sender`.
                    ipcRenderer.on(channel, (event, ...args) => listener(...args));
                }
            },
            // From main to render (once)
            receiveOnce: (channel, listener) => {
                if (ipc.channels.receiveOnce.includes(channel)) {
                    // Deliberately strip event as it includes `sender`.
                    ipcRenderer.once(channel, (event, ...args) => listener(...args));
                }
            },
            // From render to main and back again
            invoke: (channel, args) => {
                if (ipc.channels.sendReceive.includes(channel)) {
                    return ipcRenderer.invoke(channel, args);
                }
            }
        }
    );
    

    And how to use it...

    /**
     *
     * Main --> Render
     * ---------------
     * Main:    window.webContents.send('channel', data); // Data is optional.
     * Render:  window.ipcRenderer.receive('channel', (data) => { methodName(data); });
     *
     * Main --> Render (Once)
     * ----------------------
     * Main:    window.webContents.send('channel', data); // Data is optional.
     * Render:  window.ipcRenderer.receiveOnce('channel', (data) => { methodName(data); });
     *
     * Render --> Main
     * ---------------
     * Render:  window.ipcRenderer.send('channel', data); // Data is optional.
     * Main:    electronIpcMain.on('channel', (event, data) => { methodName(data); })
     *
     * Render --> Main (Once)
     * ----------------------
     * Render:  window.ipcRenderer.send('channel', data); // Data is optional.
     * Main:    electronIpcMain.once('channel', (event, data) => { methodName(data); })
     *
     * Render --> Main (Value) --> Render
     * ----------------------------------
     * Render:  window.ipcRenderer.invoke('channel', data).then((result) => { methodName(result); });
     * Main:    electronIpcMain.handle('channel', (event, data) => { return someMethod(data); });
     *
     * Render --> Main (Promise) --> Render
     * ------------------------------------
     * Render:  window.ipcRenderer.invoke('channel', data).then((result) => { methodName(result); });
     * Main:    electronIpcMain.handle('channel', async (event, data) => {
     *              return await myPromise(data)
     *                  .then((result) => { return result; })
     *          });
     *
     * Main:    function myPromise(data) { return new Promise((resolve, reject) => { ... }); }
     *
     */
    

    index.htm (render process)

    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8">
            <title>Electron Test</title>
            <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"/>
        </head>
    
        <body>
            <label for="path">Path: </label>
            <input type="text" id="path" value="" style="width: 44em;">
            <input type="button" id="openPathDialog" value="...">
    
            <hr>
    
            <textarea id="data" rows="10" cols="80" spellcheck="true" autofocus></textarea>
    
            <br><br>
    
            <input type="button" id="save" value="Save">
        </body>
    
        <script>
            let pathField = document.getElementById('path');
            let dataField = document.getElementById('data');
    
            // Populate file path field on creation of window
            window.ipcRenderer.receive('populatePath', (path) => {
                pathField.value = path;
            });
    
            document.getElementById('openPathDialog').addEventListener('click', () => {
                // Send message to main process to open file selector
                window.ipcRenderer.invoke('openPathDialog', pathField.value)
                    .then((path) => {
                        // Display path if dialog was not closed by "Cancel" button or "ESC" key
                        if (path !== undefined) { pathField.value = path; }
                    });
            })
    
            document.getElementById('save').addEventListener('click', () => {
                // Send file path and data to main process for saving
                window.ipcRenderer.send('saveData', {
                    'path': pathField.value,
                    'data': dataField.value
                });
            });
        </script>
    </html>