March 12, 2022

Build your first Electron app

A walkthrough on how to create and deploy your first desktop app (on macOS). Electron is a cross-platform desktop app framework. With the help of angular, ffmpeg and yt-dlp we will build a Youtube-downloader application with a slim user interface.

👾 Link to Github

🧑‍💻 Link to Download

The Github user maximegris already created a blank electron-angular project for us, so we don't have to configure them and have them work together out of the box. We can simply copy the project and have a development-ready project set up.

Main features

Alright, let's dive in! We want our application to download Youtube-videos and transcode them into a shareable format (mp3 or mp4). The GUI should be slim, fast and intuitive.

Electron under the hood

Electron at its base launches a process, which then runs a browser window with a specific URL. The main process runs in the background and can spawn other processes. The browser window has access to multiple device functionalities such as local file handling, microphone, camera etc.

A software architecture design pattern used by Electron is seperation of concerns. The browser window should not spawn new processes and should not have direct access to such (mainly because of security reasons). Communication between the browser and the main process is handled via IPC (Inter-Process Communication).

For our project we need to configure multiple IPC endpoints in our main process (main.ts):

ipcMain.on('ytDownload-sender-start', (event, data) => {
  processes.push(new YtDownloadProcess(data.uuid, data.link, event.sender, data.video));
});

ipcMain.on('ytDownload-sender-abort', (event, data) => {
  const processToAbort = processes.find((process) => process.uuid === data.uuid);
  if (processToAbort) {
    processToAbort.abort();
  }
});

With the first endpoint, we create a new download process and append it to the list of all processes. With the second endpoint, we abort a single process, in case the user decides to.

In addition, we want our browser window to use only as much space as needed.

ipcMain.on('resize', (event, data) => {
  win.setSize(630, data.height);
})

Every time, a new download is launched or an existing one is aborted, the window size will change. The width of the window will stay static at 630px.

Youtube download process

The YtDownloadProcess connects our main process to the youtube download processes. We use a library that wraps the yt-dlp executable and outputs JS-friendly objects.

When we start a new download, we first want to retrieve all information about the video. We pass the link and additional parameters, to retrieve the information as json-data and to indicate that we skip the download process. We handle the download process itself at a later time.

As soon as we get the information, we transmit the data (title and thumbnail) back to the sender. The sender in this case is the browser window.

this.yTDlpWrap.execPromise([this.link, '--print-json', '--skip-download'])
    .then((info) => {
        let parsedInfo = JSON.parse(info);
        this.sender.send('ytDownload-info', {
            uuid: this.uuid,
            title: parsedInfo.title,
            thumbnail: parsedInfo.thumbnail
        });
        return true;
    }).catch(() => {
        this.sender.send('ytDownload-error', {
            uuid: this.uuid,
            error: 'Information could not be retrieved'
        });
        return false;
    });

In case of an error we transmit to the sender, that the information could not have been retrieved. To identify every process we create a UUID that will be transmitted with all responses/requests. YTDownload Process

Once we retrieved the information about the video we can start the download and the transcoding. For the transcoding we will need ffmpeg. ffmpeg is a library that enables us to convert a video to an audio file, or transcode a video to another format. We use the npm module ffmpeg-static-electron that delivers the binaries for each operating system (macOS/Windows/Linux). We indicate with --ffmpeg-location where to find the binary for ffmpeg.

let config = [
    this.link, '--ffmpeg-location', ffmpeg.path,
    '--output', path.join(app.getPath('downloads'), '/').concat('%(title)s.%(ext)s')
];

Further we configure, that we need mp4 for video files and mp3 for audio files. yt-dlp will do the rest for us.

if (this.downloadVideo) {
    config.push('--format', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio');
    config.push('--merge-output-format', 'mp4');
} else {
    config.push('--extract-audio', true);
    config.push('--audio-format', 'mp3');
}

As soon as the process started, we can track the status. With the process event we get a percentage of the current download status. With the ytDlpEvent we can track if the transcoding has started. This process will return ExtractAudio for audio file transcoding and Merger for video file transcoding.

const exec = this.yTDlpWrap.exec(config)
.on('progress', (data) => {
    this.sender.send('ytDownload-download', {
        uuid: this.uuid,
        process: data.percent
    })
});
.on('ytDlpEvent', (ytDlpEvent) => {
    if (ytDlpEvent.includes('ExtractAudio') || ytDlpEvent.includes('Merger')) {
        this.sender.send('ytDownload-transcoding', { uuid: this.uuid })
    }
})

Once the process has finished, we will recieve a close event, and can indicate to the sender, that the download/transcoding is done.

exec.ytDlpProcess.on('close', () => {
    this.downloadFinished();
});

Aborting the process is pretty straightforward. We simply call SIGKILL with the kill command.

public abort(): void {
    if (this.process) {
        this.process.kill('SIGKILL');
    }
    delete this.process;
}

Frontend

For the frontend we are designing and implementing a slim user interface in dark mode. This part was fairly easy to implement, so we won't go as much in-depth.

The main part of our frontend is the download-handler.service. It consists of all communication with the main process and uses the IPC connection.

To get the gist of it, here an example of the communication. We start a new download by sending the ytDownload-sender-start event with a new link and UUID to the main process.

public startDownload(link: string): void {
  const newEntry = { uuid: uuidv4(), link: link };
  this.updateOrCreateStatus(newEntry);
  this.electronService.ipcRenderer.send('ytDownload-sender-start', newEntry);
}

On the start of the application, we set up all listeners in the browser, in case the main process sends a event to us. Here an example of the download process:

this.electronService.ipcRenderer.on('ytDownload-download', (event, data) => {
  this.updateOrCreateStatus({
    uuid: data.uuid,
    status: DownloadStatus.DOWNLOADING,
    process: data.process
  });
});

The height of the window is calculated based on the count of downloads and then send to the IPC.

this.electronService.ipcRenderer.send('resize', {
  height: 101+this.lastCount*104+(this.lastCount > 0 ? 20 : 0)
});

Et voilà, all together in action: downloader in action

Building the app

The npm-package electron-builder helps us build our electron application. I can not say that I'm entirely happy how electron-builder is documented, because building on macOS was quite quirky.

Somehow the angular-app folder was not correctly imported into the build folder. In the package.json you can configure how electron-builder should build your application. To ensure that the angular-app was copied to the build folder, I had to add a beforePack and afterPack script.

In the beforePack script I copy every file from the angular distribution folder to the /app distribution folder.

async function beforePack({targets, appOutDir}) {
    fse.copySync(path.join(__dirname, '../dist_angular'), __dirname);
}

In the afterPack script we remove all unused files and the angular application from the distribution folder.

async function afterPack({targets, appOutDir}) {
    const files = fs.readdirSync(path.join(__dirname, '../dist_angular'));
    files.forEach((file) => {
        const pathRM = path.join(__dirname, '/', file);
        fs.rmSync(pathRM, { recursive: true, force: true });
    });
}

Further, there was a problem accessing binary files (like ffmpeg) once the builder had packed the application. The solution for this problem was to indicate to electron, that some packages should not be included in the asar-file (electron specific zip file). This can be achieved with the asarUnpack property in the package.json file. To access said binary we now have to replace the app.asar folder in the filepath with the unpacked folder.

private yTDlpWrap = new YTDlpWrap(path.join(__dirname, 'yt-dlp/yt-dlp').replace('app.asar', 'app.asar.unpacked'));
Made with ♥️ and  Benjamin Mathieu, 2020