Packaging Sencha ExtJs Application With Github Electron

Sencha ExtJs provides developers the potential to develop universal applications using HTML, CSS and JavaScript. Starting from version 6.0, ExtJs apps are not only limited to desktop but are also accessible on mobile and tablets as well. But what if you want to run your application as a native application like Phonegap?. Sencha provides a way to package your application into native mobile application using Sencha Command. They were also providing chromium based desktop packager before, but now this project seems to be discontinued. So what if you want to package your application into native desktop application?. There are lot of options or tools are available for this, like Github Electron, NwJs, Tide Sdk, etc.

In this blog post we will check how we can package an ExtJs application into native desktop application using Github Electron. We will also check about how to integrate the packaging process into Sencha Command.

Github’s electron framework (formerly known as Atom Shell) lets you write cross platform applications using JavaScript, HTML and CSS. It’s based on Chromium and io.js. It supports device access through native node modules directly inside the web pages .

Creating a new ExtJs App

To get started you need to create a Sencha ExtJs Application, the best way to do that is create the application using Sencha CMD, a cross platform command line application from Sencha which provides many automated tasks around the full life cycle of your applications from generating a new project to deploying an application to production. Generating a starter extjs application is as simple as follows with sencha command:

  • Download ExtJs Sdk.
  • Download and install sencha Cmd .
  • Open your terminal and issue the following command:
sencha -sdk	/path/to/extjs/framework generate app AppName /path/to/workspace

The application files generated by the above command will have the following structure

Sencha ExtJs  Project Structure

The simplest way to run your application is to run :

sencha app watch

This command will perform a development build and start watches for changes to file system. It will also start local http server to serve files from your workspace.

To access this web server use:

http://localhost:1841

Package with Github Electron

You should have node js installed on your system. Generally our application should be structured like following:

ExtJs Application
|-- app
|-- classic
|-- ext
|-- modern
|-- ...
|--Electron
    |-- build
    |-- app
        |-- main.js
        |-- package.json
    |-- resources
        |-- installer.nsi
        |-- icon.ico
        |-- setup-banner.bmp
    |-- package.json
    |-- node_modules
|-- build.xml

There are two package.json file in our electron application.

  1. For Development. Directly under Electron folder. Here you can declare dependencies for your development environment and build scripts. We don’t have to distribute this file and dependencies listed in this file.
  2. For your appliaction. package.json inside Electron/app is the real manifest file for your application. Whenever you need to install some npm dependencies to be used in your application directly, you may add it into this package.json.

Format of package.json is exactly the same as that of Node’s modules. Your application’s startup script should be specified in main property inside your application package.son.

Electron/app/package.json might look like this:

{
    name: "app-name",
    version: "0.0.0",
    main: "main.js",
    productName: "app-name",
    description: "your product description goes here..."
}

You can create both package.json files either by entering npm init command or manually.

Then install npm dependencies need for packaging application by entering following command in your command line prompt:

npm install --save-dev electron-prebuilt fs-jetpack asar recedit Q

Note: You should change your current working directory to Electron folder in your command line prompt

Electron/app/main.js is the entry point of our electron application. This script is responsible for creating the main window and handling the system events. Copy the code below to main.js:

// Module to control application life.
var app = require('app'); 

// Module to create native browser window.
var BrowserWindow = require('browser-window'); 

var mainWindow = null;

// Quit when all windows are closed.
app.on('window-all-closed', function() {
  if (process.platform != 'darwin') {
    app.quit();
  }
});


// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.on('ready', function() {
  // Create the browser window.
  mainWindow = new BrowserWindow({width: 800, height: 600});

  // and load the index.html of the app.
  mainWindow.loadUrl('file://' + __dirname + '/index.html');

  // Open the devtools.
  // mainWindow.openDevTools();

  // Emitted when the window is closed.
  mainWindow.on('closed', function() {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    mainWindow = null;
  });
});

This script creates a chromium window and loads our ExtJs application’s index.html into the Web view.

As I mentioned above, Github Electron supports the native npm modules inside the web pages. So if you want to access the native npm modules you can do it in your client script files of html pages as shown below:

var fs = require('fs');
	
	//reads the file content
	var file = fs.readFileSync('file.txt');
	
	// Note that, DOM can directly communicate with native node modules.
	document.getElementById('id').innerHTML = file;

Creating your electron build script

We use node.js script for building the application. A typical electron distribution can be found inside Electron/node_modules/electron-prebuilt/dist directory. Our build workflow is as follows:

  1. Our very first task to copy this distribution into our build folder inside Electron.

  2. Each electron distribution contains a default application inside dist/resources/default_app folder in windows platform. We need to replace this application with our ExtJS application.

  3. To protect our application’s source code and resources from users, you can choose to package your app into an asar archive with little changes to your source code. An asar archive is a simple tar-like format that concatenate files into a single file, Electron can read arbitrary files from it without unpacking the whole file.
  4. Rename the application exe file and change the icon and other resource files.
  5. Create Installer.

Note: The steps explained here is for windows platform. In other platforms you should change the path specific to the platform.

Let’s start writing our build.js file. Import all required modules as follows:

'use strict';

var Q = require('q');
var childProcess = require('child_process');
var jetpack = require('fs-jetpack');
var asar = require('asar');

Initialise the script:

var projectDir;
	var buildDir;
	var manifest;
	var appDir;
	
	function init() {
		projectDir = jetpack;
		buildDir = projectDir.dir('./build', { empty: true });
		appDir = projectDir.dir('./app');
		manifest = appDir.read('./package.json', 'json');
		return Q();
	}

The init method assign directories to corresponding global variables. Here we use fs-jetpack node module for file operations as node’s fs modules too low level.

Copy Electron distribution

Copy the electron-prebuilt from your development node dependencies to the build directory:

function copyElectron() {
	return projectDir.copyAsync('node_modules/electron-prebuilt/dist', buildDir, { empty: true});
}

Cleanup default application

As I mentioned before Electron shipped with default application. It can be found in default_app inside resources folder. Remove files like:

function cleanupRuntime () {
    return buildDir.removeAsync('resources/default_app');
}

Create asar package as follows:

Create asar package of your extjs application as follows:

function createAsar() {
	  var deferred = Q.defer();
    asar.createPackage(appDir, buildDir.path('resources/app.asar'), function() {
        deferred.resolve();
    });
    return deferred.promise;
}

Replace the default electron icon with your own and rename the application. Copy your icons into Electron/resources folder.

function updateResources() {
	var deferred = Q.defer();

	// Copy your icon from resource folder into build folder.
    projectDir.copy('resources/icon.ico', buildDir.path('icon.ico'));

    // Replace Electron icon for your own.
    var rcedit = require('rcedit');
    rcedit(readyAppDir.path('electron.exe'), {
        'icon': projectDir.path('resources/windows/icon.ico'),
        'version-string': {
            'ProductName': manifest.productName,
            'FileDescription': manifest.description,
        }
    }, function (err) {
        if (!err) {
            deferred.resolve();
        }
    });

    return deferred.promise;
}
//Rename the electron exe 
function rename() {
    return buildDir.renameAsync('electron.exe', manifest.productName + '.exe');
}

Create installer

You can either use wix or NSIS to create windows installer. Here we use NSIS which is designed to be small and flexible as possible and is very suitable for internet distribution. With NSIS you can create such installers that are capable of doing everything that is needed to setup your software.

  1. Download and install NSIS.
  2. Create Electron/resources/installer.nsis , NSIS script as follows:
; NSIS packaging/install script
; Docs: http://nsis.sourceforge.net/Docs/Contents.html

!include LogicLib.nsh
!include nsDialogs.nsh

; --------------------------------
; Variables
; --------------------------------

!define dest ""
!define src ""
!define name ""
!define productName ""
!define version ""
!define icon ""
!define setupIcon ""
!define banner ""

!define exec ".exe"

!define regkey "Software\${productName}"
!define uninstkey "Software\Microsoft\Windows\CurrentVersion\Uninstall\${productName}"

!define uninstaller "uninstall.exe"

; --------------------------------
; Installation
; --------------------------------

SetCompressor lzma

Name "${productName}"
Icon "${setupIcon}"
OutFile "${dest}"
InstallDir "$PROGRAMFILES\${productName}"
InstallDirRegKey HKLM "${regkey}" ""

CRCCheck on
SilentInstall normal

XPStyle on
ShowInstDetails nevershow
AutoCloseWindow false
WindowIcon off

Caption "${productName} Setup"
; Don't add sub-captions to title bar
SubCaption 3 " "
SubCaption 4 " "

Page custom welcome
Page instfiles

Var Image
Var ImageHandle

Function .onInit

    ; Extract banner image for welcome page
    InitPluginsDir
    ReserveFile "${banner}"
    File /oname=$PLUGINSDIR\banner.bmp "${banner}"

FunctionEnd

; Custom welcome page
Function welcome

    nsDialogs::Create 1018

    ${NSD_CreateLabel} 185 1u 210 100% "Welcome to ${productName} version ${version} installer.$\r$\n$\r$\nClick install to begin."

    ${NSD_CreateBitmap} 0 0 170 210 ""
    Pop $Image
    ${NSD_SetImage} $Image $PLUGINSDIR\banner.bmp $ImageHandle

    nsDialogs::Show

    ${NSD_FreeImage} $ImageHandle

FunctionEnd

; Installation declarations
Section "Install"

    WriteRegStr HKLM "${regkey}" "Install_Dir" "$INSTDIR"
    WriteRegStr HKLM "${uninstkey}" "DisplayName" "${productName}"
    WriteRegStr HKLM "${uninstkey}" "DisplayIcon" '"$INSTDIR\icon.ico"'
    WriteRegStr HKLM "${uninstkey}" "UninstallString" '"$INSTDIR\${uninstaller}"'

    ; Remove all application files copied by previous installation
    RMDir /r "$INSTDIR"

    SetOutPath $INSTDIR

    ; Include all files from /build directory
    File /r "${src}\*"

    ; Create start menu shortcut
    CreateShortCut "$SMPROGRAMS\${productName}.lnk" "$INSTDIR\${exec}" "" "$INSTDIR\icon.ico"

    WriteUninstaller "${uninstaller}"

SectionEnd

; --------------------------------
; Uninstaller
; --------------------------------

ShowUninstDetails nevershow

UninstallCaption "Uninstall ${productName}"
UninstallText "Don't like ${productName} anymore? Hit uninstall button."
UninstallIcon "${icon}"

UninstPage custom un.confirm un.confirmOnLeave
UninstPage instfiles

Var RemoveAppDataCheckbox
Var RemoveAppDataCheckbox_State

; Custom uninstall confirm page
Function un.confirm

    nsDialogs::Create 1018

    ${NSD_CreateLabel} 1u 1u 100% 24u "If you really want to remove ${productName} from your computer press uninstall button."

    ${NSD_CreateCheckbox} 1u 35u 100% 10u "Remove also my ${productName} personal data"
    Pop $RemoveAppDataCheckbox

    nsDialogs::Show

FunctionEnd

Function un.confirmOnLeave

    ; Save checkbox state on page leave
    ${NSD_GetState} $RemoveAppDataCheckbox $RemoveAppDataCheckbox_State

FunctionEnd

; Uninstall declarations
Section "Uninstall"

    DeleteRegKey HKLM "${uninstkey}"
    DeleteRegKey HKLM "${regkey}"

    Delete "$SMPROGRAMS\${productName}.lnk"

    ; Remove whole directory from Program Files
    RMDir /r "$INSTDIR"

    ; Remove also appData directory generated by your app if user checked this option
    ${If} $RemoveAppDataCheckbox_State == ${BST_CHECKED}
        RMDir /r "$LOCALAPPDATA\${name}"
    ${EndIf}

SectionEnd

Next we need to define a node function to process this script and execute create installer. So define the createInstaller function in your build.js like following:

function createInstaller() {
    var deferred = Q.defer();

    var finalPackageName = manifest.name + '_' + manifest.version + '.exe';

    function replace(str, patterns) {
	    Object.keys(patterns).forEach(function (pattern) {
	        var matcher = new RegExp(' + pattern + ', 'g');
	        str = str.replace(matcher, patterns[pattern]);
	    });
	    return str;
    }

    var installScript = projectDir.read('resources/installer.nsi');
    installScript = replace(installScript, {
        name: manifest.name,
        productName: manifest.productName,
        version: manifest.version,
        src: buildDir.path(),
        dest: projectDir.path(finalPackageName),
        icon: buldDir.path('icon.ico'),
        setupIcon: projectDir.path('resources/setup-icon.ico'),
        banner: projectDir.path('resources/setup-banner.bmp'),
    });
    buildDir.write('installer.nsi', installScript);

    // Note: NSIS have to be added to PATH (environment variables).
    var nsis = childProcess.spawn('makensis', [
        buildDir.path('installer.nsi')
    ], {
        stdio: 'inherit'
    });
    nsis.on('error', function (err) {
        if (err.message === 'spawn makensis ENOENT') {
            throw "Can't find NSIS. Are you sure you've installed it and"
                + " added to PATH environment variable?";
        } else {
            throw err;
        }
    });
    nsis.on('close', function () {
        deferred.resolve();
    });

    return deferred.promise;
};

createInstaller method first replaces the nsis script with project specific values for name and icon. Then it executes the script with makensis command. Note that you should have NSIS installed on your system and also it should be available in your path.

Combine all together

Combine all the tasks together and export the module:

module.exports = function () {
    return init()
    .then(copyRuntime)
    .then(cleanupRuntime)
    .then(createAsar)
    .then(updateResources)
    .then(rename)
    .then(createInstaller);
};

Automating the process with sencha command.

It’s a big frustration switching back and forth from sencha command to node js command line each time when we build the application. So we need to integrate this nodejs build in to the sencha command. The current build artifacts should be copied into Electron directory and build.js` should be executed whenever you build your extjs application.

Whenever you build your application with sencha command, the the build.js should be executed. For this, Sencha command provides lot of extension points where you can hook your custom ant tasks. Here our task is to copy the current build into workspace/electron/app directory and execute our build.js file. Paste the code below into to the build.xml file in your application root.

<target name="-after-build">  
        <delete includeemptydirs="true">
             <fileset dir="Electron/app">
                 <exclude name="package.json" />
                 <exclude name="main.js" />
                 <include name="**/*" />
             </fileset>
        </delete>
        <copy todir="Electron/app">
            <fileset dir="${build.dir}" />
        </copy>
        <exec dir="${app.dir}/Electron" executable="node">
            <arg value="build.js"/>
        </exec>
    </target>

You may want to change the target to -after-watch , if you want to integrate sencha watch command with electron build.

Build your application:

sencha app build production

You can find the native desktop version of your application in Electron folder.

Complete code for this blog post is hosted into Github.

comments powered by Disqus