Book Image

Cross-platform Desktop Application Development: Electron, Node, NW.js, and React

By : Dmitry Sheiko
Book Image

Cross-platform Desktop Application Development: Electron, Node, NW.js, and React

By: Dmitry Sheiko

Overview of this book

Building and maintaining cross-platform desktop applications with native languages isn’t a trivial task. Since it’s hard to simulate on a foreign platform, packaging and distribution can be quite platform-specific and testing cross-platform apps is pretty complicated.In such scenarios, web technologies such as HTML5 and JavaScript can be your lifesaver. HTML5 desktop applications can be distributed across different platforms (Window, MacOS, and Linux) without any modifications to the code. The book starts with a walk-through on building a simple file explorer from scratch powered by NW.JS. So you will practice the most exciting features of bleeding edge CSS and JavaScript. In addition you will learn to use the desktop environment integration API, source code protection, packaging, and auto-updating with NW.JS. As the second application you will build a chat-system example implemented with Electron and React. While developing the chat app, you will get Photonkit. Next, you will create a screen capturer with NW.JS, React, and Redux. Finally, you will examine an RSS-reader built with TypeScript, React, Redux, and Electron. Generic UI components will be reused from the React MDL library. By the end of the book, you will have built four desktop apps. You will have covered everything from planning, designing, and development to the enhancement, testing, and delivery of these apps.
Table of Contents (9 chapters)

Writing a service to navigate through directories

Other modules, such as FileListView, DirListView, and TitleBarPath, consume the data from the filesystem, such as directory list, file list, and the current path. So we need to create a service that will provide this data:

./js/Service/Dir.js

const fs = require( "fs" ), 
{ join, parse } = require( "path" );

class DirService {

constructor( dir = null ){
this.dir = dir || process.cwd();
}

static readDir( dir ) {
const fInfoArr = fs.readdirSync( dir, "utf-8" ).map(( fileName ) => {
const filePath = join( dir, fileName ),
stats = DirService.getStats( filePath );
if ( stats === false ) {
return false;
}
return {
fileName,
stats
};
});
return fInfoArr.filter( item => item !== false );
}

getDirList() {
const collection = DirService.readDir( this.dir ).filter(( fInfo )
=> fInfo.stats.isDirectory() );
if ( !this.isRoot() ) {
collection.unshift({ fileName: ".." });
}
return collection;
}

getFileList() {
return DirService.readDir( this.dir ).filter(( fInfo ) =>
fInfo.stats.isFile() );
}

isRoot(){
const { root } = parse( this.dir );
return ( root === this.dir );
}

static getStats( filePath ) {
try {
return fs.statSync( filePath );
} catch( e ) {
return false;
}
}

};

exports.DirService = DirService;

First of all, we import Node.js core module fs that provides us access to the filesystem. We also extract functions--join and parse--from the path module. We will need them for manipulations in the file/directory path.

Then, we declare the DirService class. On construction, it creates a dir property, which takes either a passed-in value or the current working directory (process.cwd()). We add a static method--readDir--to the class that reads the directory content on a given location. The fs.readdirSync method retrieves the content of a directory, but we extend the payload with file/directory stats (https://nodejs.org/api/fs.html#fs_class_fs_stats). In case the stats cannot be obtained, we replace its array element with false. To avoid such gaps in the output array, we will run the array filter method. Thus, on the exit point, we have a clean array of filenames and file stats.

The getFileList method requests readDir for the current directory content and filters the list to leave only files in there.

The getDirList method filters, evidently, the list for directories only. Besides, it prepends the list with a .. directory for upward navigation, but only if we are not in the system root.

So, we can get both lists from the modules consuming them. When the location changes and new directory and file lists get available, each of these modules have to update. To implement it, we will use the observe pattern:

./js/Service/Dir.js

//.... 
const EventEmitter = require( "events" );

class DirService extends EventEmitter {

constructor( dir = null ){
super();
this.dir = dir || process.cwd();
}
setDir( dir = "" ){
let newDir = path.join( this.dir, dir );
// Early exit
if ( DirService.getStats( newDir ) === false ) {
return;
}
this.dir = newDir;
this.notify();
}

notify(){
this.emit( "update" );
}
//...
}

We export from events, core module the EventEmitter class (https://nodejs.org/api/events.html). By extending it with DirService, we make the service an event emitter. It gives us the possibility to fire service events and to subscribe on them:

dirService.on( "customEvent", () => console.log( "fired customEvent" )); 
dirService.emit( "customEvent" );

So whenever the setDir method is called to change the current location, it fires an event of type "update". Given the consuming modules are subscribed, they respond to the event by updating their views.

Unit-testing a service

We've written a service and assume that it fulfills the functional requirements, but we do not know it for sure, yet. To check it, we will create a unit-test.

We do not have any test environment so far. I would suggest going with the Jasmine test framework (https://jasmine.github.io/). We will create in our tests/unit-tests subfolder a dedicated NW.js project, which will be used for the testing. This way, we get the runtime environment for tests, identical to what we have in the application.

So we create the test project manifest:

./tests/unit-tests/package.json

{ 
"name": "file-explorer",
"main": "specs.html",
"chromium-args": "--mixed-context"
}

It points at the Jasmine test runner page, the one we placed next to package.json:

./tests/unit-tests/specs.html

<!doctype html> 
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner</title>
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.5.2/jasmine.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.5.2/jasmine.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.5.2/jasmine-
html.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.5.2/boot.js"></script>
</head>
<body>
<div id="sandbox" style="display: none"></div>
<script>
// Catch exception and report them to the console.
process.on( "uncaughtException", ( err ) => console.error( err ) );
const path = require( "path" ),
jetpack = require( "fs-jetpack" ),
matchingSpecs = jetpack.find( "../../js", {
matching: [
"*.spec.js",
"!node_modules/**"
]
}, "relativePath" );

matchingSpecs.forEach(( file ) => {
require( path.join( __dirname, file ) );
});
</script>
</body>
</html>

What does this runner do? It loads Jasmine, and with help of the fs-jetpack npm module (https://www.npmjs.com/package/fs-jetpack), it traverses the source directory recursively for all the files matching "*.spec.js" pattern. All these files get added to the test suite. Thus, it assumes that we keep our test specifications next to the target source modules.

fs-jetpack is an external module, and we need to install the package and add it to the development dependencies list:

npm i -D fs-jetpack

Jasmine implements a wide-spread, frontend development testing paradigm Behavior-driven Development (BDD) that can be described with the following pattern:

 
describe( "a context e.g. class or module", () => {
describe( "a context e.g. method or function", () => {
it( "does what expected", () => {
expect( returnValue ).toBe( expectedValue );
});
});
});

As it is generally accepted in unit testing, a suite may have setup and teardown:

beforeEach(() => { 
// something to run before to every test
});
afterEach(() => {
// something to run after to every test
});

When testing a service that touches the filesystem or communicates across the network or talks to databases, we have to be careful. A good unit test is independent from the environment. So, to test our DirService, we have to mock the filesystem. Let's test the getFileList method of the service class to see it in action:

./js/Service/Dir.spec.js

const { DirService } = require( "./Dir" ), 
CWD = process.cwd(),
mock = require( "mock-fs" ),
{ join } = require( "path" );

describe( "Service/Dir", () => {

beforeEach(() => {
mock({
foo: {
bar: {
baz: "baz", // file contains text baz
qux: "qux"
}
}
});
});
afterEach( mock.restore );

describe( "#getFileList", () => {
it( "receives intended file list", () => {
const service = new DirService( join( "foo", "bar" ) );
service.setDir( "bar" );
let files = service.getFileList();
expect( files.length ).toBe( 2 );
});
it( "every file has expected properties", () => {
const service = new DirService( join( "foo", "bar" ) );
const files = service.getFileList();
console.log( files );
const [ file ] = files;
expect( file.fileName ).toBe( "baz" );
expect( file.stats.size ).toBe( 3 );
expect( file.stats.isFile() ).toBe( true );
expect( file.stats.isDirectory() ).toBe( false );
expect( file.stats.mtime ).toBeTruthy();
});
});
});

Before running a test, we point the fs method to a virtual filesystem with the folder /foo/bar/ that contains the baz and qux files. After every test, we restore access to the original filesystem. In the first test, we instantiate the service on the foo/bar location and read the content with the getFileList() method. We assert the number of found files as 2 (as we defined in beforeEach). In the second test, we take the first element of the list and assert that it contains the intended filename and stats.

As we use an external npm package (https://www.npmjs.com/package/mock-fs) for mocking, we need to install it:

npm i -D mock-fs

As we came up with the first test suite, we can modify our project manifest file for a proper test runner script. The ./package.json file contains the following code:

{ 
...
"scripts": {
...
"test": "nw tests/unit-tests"
},
...
}

Now, we can run the tests:

npm test

NW.js will load and render the following screen:

Ideally, unit tests cover all the available functions/methods in the context. I believe that from the preceding code you will get an idea of how to write the tests. However, you may stumble over testing the EventEmitter interface; consider this example:

 
describe( "#setDir", () => {
it( "fires update event", ( done ) => {
const service = new DirService( "foo" );
service.on( "update", () => {
expect( true ).toBe( true );
done();
});
service.notify();
});
});

EventEmitter works asynchronously. When we have asynchronous calls in the test body, we shall explicitly inform Jasmin when the test is ready so that the framework could proceed to the next one. That happens when we invoke the callback passed to its function. In the preceding sample, we subscribe the "update" event on the service and call notify to make it fire the event. As soon as the event is captured, we invoke the done callback.