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?
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:
textarea
field for entering example data.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:
.json
data to a file instead of plain text.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>