A while ago, I ran into a situation where I needed to make web application into a desktop application for a corporate client. The idea was to leverage as much of the existing app as possible, while still meeting the requirement of having a “desktop” application.
As a primarily Java developer, I had no idea how to do this, so I started doing research and found out that my best bet would be using JavaScript technologies.
Enter NW.js, a powerful mashup of Google Chrome and Node.js that allows you to package a web application to be run on the desktop.
I think the NW.js site describes the tool the best:
NW.js (previously known as node-webkit) lets you call all Node.js modules directly from DOM and enables a new way of writing applications with all Web technologies.
Using NW.js, you can, for example, open a web application in the embedded browser and inject JavaScript code at runtime. For the application in question, the biggest desire was to have a mechanism for handling internal links to the application, as well as links contained in in-app messages that should be opened in the browser.
In my use-case, the application needed to be distributed to end-users using enterprise software management procedures. This required an installable application, preferably using the MSI package format, as this would allow the administrators to remotely install the software on the target desktop computers easily. This ruled out a Chrome App.
As for Electron, which is a powerful and popular tool, I prefer NW.js because it feels more like I’m working on a web page, instead of writing a desktop application in JavaScript. If the requirements were to write a desktop-first tool though, I would probably use Electron as the code samples and documentation definitely lean strongly in this direction.
Another NW.js win, for me at least, is that I can use standard DevTools to debug my development, same as I’d do in Chrome. The fact that I don’t need to learn many new tools or paradigms, but can get down to solving the problem at hand quickly, is a clear winner for me.
Electron is really powerful and drives many desktop apps that I use, including Visual Studio Code. My philosophy has always to try to use the right tool for the job. In this instance, I wanted to encapsulate an existing web application and turn it into a basic desktop application. NW.js met all the criteria and seemed the easier tool to use.
Last, but not least, was the issue of the site I needed to wrap having a frame breaker script. The site would attempt to detect whether it was embedded in an iframe
and then try to break out of it. There is built-in support in NW.js to prevent this and, at the time, it seemed like the only tool with an easy way of doing so.
We want to wrap the TEARS website as a desktop application, allowing you to navigate the site as normal, but open some links in an external browser. We’ll use the default system browser for this.
For the record, TEARS is my favorite animal rescue organization in South Africa.
We’ll be using the latest stable version of Node.js for our experiment. The version used for this article is 0.31.5 which contains Chromium 67 and Node 10.6.0 in the application.
Here you can find the download for your platform. I prefer the SDK distribution, as it contains the powerful DevTools utility for debugging. If you get stuck, you can refer to the documentation or the Stack Overflow pages for NW.js.
Make sure you run through the getting started tutorial to make verify your setup is complete and working as expected.
We can now begin to build our solution, which will display the TEARS website as a desktop application. Site links should navigate as expected, but external links should launch in the default system browser.
Initially, we just need a way to display the site inside our NW.js application.
Our first step is to create our package.json file:
{
"name": "The Polyglot Developer NW.js Article",
"main": "index.html",
"window": {
"title": "Polyglot Developer NW.JS Desktop Wrapper",
"width": 1024,
"height": 800
}
}
Next up, we need to create the index.html file that will be the driver of our desktop application:
<html>
<head>
<title>Polyglot Developer Desktop App</title>
<head>
<body>
<iframe id="myframe"
src="http://tears.org.za/"
style="width: 100%; height:90%;"
seamless="true"
nwdisable
nwfaketop>
</iframe>
<p>
Desktop View
</p>
</body>
</html>
So what’s happening here? We are creating an iframe that contains our target web url, in this case, the TEARS web site. Take note of nwdisable
and nwfaketop
. These parameters fool the embedded frame into thinking it is the parent frame, allowing us to do all kinds of magic without the site knowing that it is being manipulated.
You should see the following:
Notice how the website is embedded in the window, with the words Desktop View visible at the bottom of the page.
Now that we can display the site, we should inject some code into the page. Our first challenge is to know when to inject our code. Ideally, we want to do it once the page has loaded. For that, the onLoad
property of the iframe is ideal. Add this to the iframe:
onLoad="isLoaded();"
Now we need to add an isLoaded
function. This function will monitor our site loading progress, displaying an alert when the page is ready for us to work with.
Add the following to the head
section of your HTML page:
<script type="text/javascript">
function isLoaded() {
// let's see if the iframe has finished loading
var iframe = document.getElementById('myframe');
if (iframe && iframe.contentWindow && iframe.contentWindow.document && iframe.contentWindow.document.body && iframe.contentWindow.document.body.innerHTML) {
alert("We have liftoff!");
} else {
// iframe is not loaded yet, let's wait a bit and try again
setTimeout(isLoaded, 200);
}
};
</script>
Once you run this new code, you will see an alert popping up once the site has finished loading.
You should see the following once the page has finished loading:
Now that we know when the page is loaded, we can start injection code into it. To keep it simple, I’m only going to intercept clicks, but you can expand this method to intercept other events as well.
Remove the alert(...)
instruction and replace it with:
iframe.contentWindow.document.body.addEventListener('click', handleLinks, false);
Before we can access the Node portion of NW.js, we need to include it in our script. Insert this bit of code as the first line of your <script>
entry:
window.gui = require('nw.gui');
We now need to add the handleLinks
function to help our app determine what to do when a link is clicked:
handleLinks = function (event) {
var href;
function checkLinks(element) {
if (element.nodeName.toLowerCase() === 'a') {
href = element.getAttribute('href');
if (href) {
if (href.indexOf("tears.org.za") == -1) {
gui.Shell.openExternal(href);
// important, prevent the default event from happening!
event.preventDefault();
}
}
} else if (element.parentElement) {
checkLinks(element.parentElement);
}
}
checkLinks(event.target);
};
We handle links differently, depending on whether they are pointing to the TEARS website or not. External links make use of the gui.Shell
command to open them in your default system browser.
When you run the application, you should see the TEARS website load and be able to navigate. Scroll down to the footer of the page, and click on the social media buttons and they should open in your default system browser.
The final result should look like the following:
Here is the full, final copy of the index.html for comparison:
<html>
<head>
<title>Polyglot Developer Desktop App</title>
<script type="text/javascript">
window.gui = require('nw.gui');
handleLinks = function (event) {
var href;
function checkLinks(element) {
if (element.nodeName.toLowerCase() === 'a') {
href = element.getAttribute('href');
if (href) {
if (href.indexOf("tears.org.za") == -1) {
gui.Shell.openExternal(href);
// important, prevent the default event from happening!
event.preventDefault();
}
}
} else if (element.parentElement) {
checkLinks(element.parentElement);
}
}
checkLinks(event.target);
};
function isLoaded() {
// let's see if the iframe has finished loading
var iframe = document.getElementById('myframe');
if (iframe && iframe.contentWindow && iframe.contentWindow.document && iframe.contentWindow.document.body && iframe.contentWindow.document.body.innerHTML) {
iframe.contentWindow.document.body.addEventListener('click', handleLinks, false);
} else {
// iframe is not loaded yet, let's wait a bit and try again
setTimeout(isLoaded, 200);
}
};
</script>
<head>
<body>
<iframe
id="myframe"
src="http://tears.org.za/"
style="width: 100%; height:90%;"
seamless="true"
onLoad="isLoaded();"
nwdisable
nwfaketop>
</iframe>
<p>
Desktop View
</p>
</body>
</html>
As you can see, this was a very basic use-case. There are, of course, limits to what you can do, mostly depending on the quality of the underlying website. It can be a lot of work to intercept different frameworks and links created at runtime, as with many enterprise applications.
This approach will not yield the same level of desktop application as you would have had if you had specifically built the app to be desktop-orientated from the beginning. While it is possible to inject new JavaScript and CSS resources into the application, since you have full access to the DOM, it can be a lot of work.
The power of NW.js is that you have access to the full Node context, right from within your app. Your JavaScript code can call on all Node modules and make use of them. This leads to the ability to add many extensions and enhancements to existing web apps when you package them this way.
For example, you could override the graph rendering engine used in an older web application and substitute something like D3 instead, greatly enhancing an existing application without an extensive rewrite.
This was a brief introduction to NW.js and some of the tricks you can employ to make sites work for you. There’s much more information available in the official documentation, but this should get you started.
To see some of the really impressive applications developed using this technology, visit Awesome NW.js. For more help, reach out to me on my website NoFuss Solutions.