I’ve heard of Electron but have never used it outside of cloning and launching one of the example apps from their README. Instead of learning a new programming language or framework, I decided it was time to see if I could re-purpose my Javascript skills by using Electron while still creating a good user experience.
If you have been following my recent articles then you know that I am in the process of creating an image hosting service called Eikona. I currently have a working prototype for the web app and the back-end services. The only missing piece is a desktop app that can be packaged and installed to sync data between the client and Eikona services. If you want to catch-up with where I am at on the journey then feel free to checkout my last article below.
Setting up Electron with Vue.js
I won’t spend a ton of time going through the process of setting up Electron with Vue.js as there are tons of resources and documentation surrounding this topic. However, I will briefly list the commands and steps that I took so that we can get to the good stuff — the code.
I used the vue-cli to create the initial scaffolding for my project, then I used cd to get into the project directory and finally vue add electron-builder and viola. I was ready to develop a desktop app with electron using my Vue.js and Javascript skills.
I was surprised how much smoother the electron setup process was compared to my previous attempts at using it. With everything setup I added Vuetify (vue add vuetify) for the UI components, CSS grid and utility helpers and I was off to a great start for building my prototype.
Basic UI
My first step was to build a basic UI for performing a local sync.
Setup Electron OS File Explorer Functionality
The electron dialog.showOpenDialog opens the operating system’s file explorer. The properties options let you set if you want to allow files and directories. Once a file or directory is selected the filePath is returned and sent to the UI in order to populate the sync form.
ipcMain.on('open-file-dialog', (event: any, arg: string) => {
dialog.showOpenDialog({
properties: ['openFile', 'openDirectory']
}).then(result => {
event.sender.send('selected-directory', {
caller: arg,
result: result.filePaths[0] + '/'
});
}).catch(err => {
console.log(err)
})
});
Setup rsync with Node.js
The Electron main process thread is waiting for the start-sync event to be emitted from the ipcRenderer before initializing rsync. The flags
- a — archive, recursively syncs files and folders while preserving file metadata
- v — verbose, in order to display progress to stdout for sending to the UI
- z — compress data, useful for slow connections
- progress() — ensures that the progress is piped into stdout and available in rsync.execute.
- source() — the directory that you want to sync
- destination() — the server or on disk location you want to sync to
I run rsync.execute because it returns a PID that allows me to kill the process to ensure that I don’t keep any of the connections open if the app is closed or something goes wrong.
You will also notice that I return an event to the sender when the sync is complete with sync-complete.
sync-inprogress sends the progress of the rsync command to the UI.
ipcMain.on('start-sync', (event: any, arg: any) => {
// TODO: Kind of dangerous. Probably best to set allowed paths/routes
var rsync = new Rsync()
.flags('avz')
.progress()
.source(arg.syncPath)
.destination(arg.outputPath);
var rsyncPid = rsync.execute(
function (error: any, code: any, cmd: any) {
// Emit that the sync is completed
event.sender.send('sync-complete');
// we're done`
}, function(data: any){
// do things like parse progress
console.log(data.toString());
event.sender.send('sync-inprogress', data.toString());
}, function(data: any) {
// do things like parse error output
}
);
var quitting = function() {
if (rsyncPid) {
rsyncPid.kill();
}
process.exit();
}
ipcMain.on('kill-sync', (event: any, arg: string) => {
quitting();
});
process.on("SIGINT", quitting); // run signal handler on CTRL-C
process.on("SIGTERM", quitting); // run signal handler on SIGTERM
process.on("exit", quitting);
});
Add ipcRenderer to Vue.js
Adds the ipcRenderer object to the browser window object so that it can be referenced within Vue.js once the preload.js is added to the Electron app Javascript bundle.
Connect preload.js to Electron Main Process Thread
Line 23 is where the preload.js file was added to the Electron bundle.
Setup the UI to communicate with the Electron Main Process Thread
All of the ipcRenderer event listeners are in the mounted() lifecycle hook because they are always available (listening) once the UI is displayed.
The interesting part of this code is the scrollTo functionality. This functionality automatically scrolls to the end of the rsync progress log on the UI as it is being streamed. This allows the user to always see the latest progress without having to continuously scroll. In order to achieve this functionality I installed the vue-scrollto package and added an html element with an id that is at the bottom of the rsync progress log so that the scrollTo component has a fixed UI element to scroll to. Within the scrollTo functionality I set the state for isScrolling in onStart and onDone so that multiple scrollTo events arn’t started while the previous one is finishing because the sync-inprogress event is continuously being fired during an active sync.
<script lang="ts">
import Vue from 'vue';
import { Component } from "vue-property-decorator";
import VueScrollTo from 'vue-scrollto';
@Component({
name: "App",
components: {},
})
export default class App extends Vue {
private ipcRenderer: any = (window as any).ipcRenderer;
public syncPath = "";
public outputPath = "";
public isScrolling = false;
public disableSync = false;
public syncCompleted = false;
public syncOutput: string[] = [];
created() {
window.addEventListener('beforeunload', () => {
this.ipcRenderer.send('kill-sync');
})
}
beforeDestory() {
// Remove event listener
window.removeEventListener('beforeunload', () => null);
}
mounted() {
this.ipcRenderer.on('sync-complete', (event: any, data: any) => {
// Sync completed
this.syncCompleted = true;
this.disableSync = false;
});
this.ipcRenderer.on('selected-directory', (event: any, data: any) => {
(this as any)[`${data.caller}Path`] = data.result;
});
this.ipcRenderer.on('sync-inprogress', (event: any, data: any) => {
this.syncOutput = [...this.syncOutput, data];
const el = document.getElementById('scroll-target');
if(this.syncOutput.length > 0 && el && !this.isScrolling) {
VueScrollTo.scrollTo('#scroll-target', 1000, {
container: '.output-container',
easing: 'ease-in',
offset: -70,
force: true,
cancelable: false,
onStart: (element: any) => {
// scrolling started
this.isScrolling = true;
},
onDone: (element: any) => {
// scrolling is done
this.isScrolling = false;
},
onCancel: function() {
// scrolling has been interrupted
},
x: false,
y: true
});
}
});
}
public handleSelectSyncDir() {
this.ipcRenderer.send('open-file-dialog', 'sync');
}
public handleSelectOutputDir() {
this.ipcRenderer.send('open-file-dialog', 'output');
}
public handleKillSync() {
this.ipcRenderer.send('kill-sync');
}
public handleStartSync() {
this.disableSync = true;
this.ipcRenderer.send('start-sync', {
syncPath: this.syncPath,
outputPath: this.outputPath
});
}
}
</script>
Cover UI Edge Cases
Ensure that whenever a browser refresh event is detected emit kill-sync so that if a sync is in-progress the connection is not left open while the app is closed.
created() {
window.addEventListener('beforeunload', () => {
this.ipcRenderer.send('kill-sync');
})
}
Ensures that the beforeunload event listener is removed when the Vue instance is destroyed. This prevents leaking event handlers.
beforeDestory() {
// Remove event listener
window.removeEventListener('beforeunload', () => null);
}
Putting it all together we get
Final Results
This is prototype code to display the ease of use and power of electron. Not suitable for production.
If you found this article interesting and want me to post more on Electron as I add functionality to this app please leave a comment below. I plan on adding remote sync, auth, error handing, dock to system status bar, and other functionality to this app in the near future.