Recently, we built NeetoRecord, a loom alternative. The desktop application was built using Electron. In a series of blogs, we capture how we built the desktop application and the challenges we ran into. This blog is part 6 of the blog series. You can also read about part 1, part 2, part 3, part 4 and part 5.
When developing a desktop application, including every feature directly within the app is often unnecessary. Instead, we can offload some tasks such as login/signup to a web application. And from the web app, create deep-links to the desktop app. We can also create sharable links that opens specific content on the app.
In this blog, we are going to discuss how to create deep-links in our Electron application.
Register a custom protocol
A protocol is a custom URL scheme that an application can handle, similar to how browsers handle protocols like http, https, mailto, or ftp. Every operating system supports the handling of custom protocols. We can register an application as a default handler for a custom protocol with the operating system. Electron provides a simple API to register a default protocol client.
1if (process.defaultApp) { 2 if (process.argv.length >= 2) { 3 app.setAsDefaultProtocolClient("my-app", process.execPath, [ 4 path.resolve(process.argv[1]), 5 ]); 6 } 7} else { 8 app.setAsDefaultProtocolClient("my-app"); 9}
The code snippet above is a simple example of registering a default protocol client. In the example, we registered a custom protocol named my-app with the operating system. It's important to note that the application must be properly packaged and installed so that the operating system can correctly handle and launch the registered app.
We used process.defaultApp instead of app.isPackaged because Electron allows us to run a packaged app using the electron . command. In such cases, we need to provide the execution path for the system to recognize the app. However, if the app is running in normal mode, simply calling app.setAsDefaultProtocolClient is sufficient.
Handling custom protocol
Though registering the protocol is simple and the same for every platform, there are some differences when it comes to handling it.
For macOS, we need to listen to the open-url event.
1const handleCustomUrl = url => { 2 // Handle url 3}; 4 5app.on("open-url", (event, url) => { 6 handleCustomUrl(url); 7});
On both Windows and Linux, if the application is up and running, a second-instance event is emitted instead of open-url when a protocol request is received. This means a new instance of our app will be created. If we want to avoid this behavior and notify the existing instance instead, we'll need to ensure the app runs as a single instance. We can achieve this by using requestSingleInstanceLock.
1const gotTheLock = app.requestSingleInstanceLock(); 2 3if (!gotTheLock) { 4 app.quit(); 5}
The above code ensures that our Windows or Linux app runs as a single instance. If a user attempts to open a new instance, that instance will try to acquire the single instance lock. If it fails, the newly created instance will simply quit.
1const gotTheLock = app.requestSingleInstanceLock(); 2 3if (!gotTheLock) { 4 app.quit(); 5} else { 6 app.on("second-instance", (event, commands, workingDir) => { 7 handleCustomUrl(commands.pop()); 8 }); 9}
If we successfully acquire the single instance lock, it means we're running the first instance of the app. When a user attempts to open another instance, Electron emits a second-instance event to the existing instance. The second argument of this event contains an array of command-line arguments, and we can retrieve the custom URL from the last item in this array.
We've addressed the case where the app is already running, but what happens if the app is completely closed and a protocol request is made? On macOS, there's nothing extra to do; the app will launch, and the open-url event will be triggered automatically.
However, for Windows and Linux, the behavior is different. The second-instance event won't be triggered since the app is starting for the first time. Instead, we can retrieve the custom URL from process.argv, as the app will start with the protocol URL passed as one of its parameters. To handle this case, we need to check process.argv when the app is started.
1const handleCustomUrl = url => { 2 // Handle url 3}; 4 5app.whenReady().then(() => { 6 const customUrl = process.argv.find(item => item.startsWith("my-app://")); 7 if (customUrl) { 8 handleCustomUrl(customUrl); 9 } 10});
Here, when the app is ready, we look for an item in process.argv that starts with our URL scheme (my-app://), if yes we can confirm that this instance is started when a protocol request is received.
Great! Here's the complete solution that works across all platforms, whether the app is already running or not.
1if (process.defaultApp) { 2 if (process.argv.length >= 2) { 3 app.setAsDefaultProtocolClient("my-app", process.execPath, [ 4 path.resolve(process.argv[1]), 5 ]); 6 } 7} else { 8 app.setAsDefaultProtocolClient("my-app"); 9} 10 11const handleCustomUrl = url => { 12 // Handle url 13}; 14 15app.on("open-url", (event, url) => { 16 handleCustomUrl(url); 17}); 18 19const gotTheLock = app.requestSingleInstanceLock(); 20 21if (!gotTheLock) { 22 app.quit(); 23} else { 24 app.on("second-instance", (event, commands, workingDir) => { 25 handleCustomUrl(commands.pop()); 26 }); 27} 28 29app.whenReady().then(() => { 30 const customUrl = process.argv.find(item => item.startsWith("my-app://")); 31 if (customUrl) { 32 handleCustomUrl(customUrl); 33 } 34});
Packaging
On macOS and Linux, this feature is only functional when our app is packaged; it won't work during development when launching from the command line. To ensure proper functionality, we must update the macOS Info.plist file and the Linux .desktop file to include the new protocol handler when packaging our app. This allows the operating system to recognize and handle the custom URLs correctly.
electron-builder handles this internally when packaging the app; We just need to configure the electron-builder accordingly. To learn more about how to package our app using electron-builder, check out this blog.
1"build": { 2 "productName": "NeetoRecord", 3 "appId": "com.neeto.neetoRecord", 4 "protocols": { 5 "name": "my-app-protocol", 6 "schemes": [ 7 "my-app" 8 ] 9 }, 10 "win": {...}, 11 "linux": {...}, 12 "mac": {...} 13} 14
We can use the protocols field for that, give a name, then pass an array of URL schemes the app supports, and the rest electron-builder will handle it.