Upcoming and OnDemand Webinars View full list

Ember.JS and Electron: Build Desktop Apps with Web Technologies

Garry Smith

Building cross-platform desktop and mobile apps with web technologies is not a new topic (in fact, we talked about hybrid apps on the blog just yesterday). There are many different flavors that have arisen over the years, allowing you to build and deploy on a myriad of platforms using a single codebase. Each have their own varying benefits, drawbacks and community support. Here are just a few:

Electron has a straightforward API and is incredibly easy to set up. With a single code base, you can write apps for macOS, Windows and Linux. On the web framework side, we have been using Ember.JS at Big Nerd Ranch for a while now on client projects. We even teach it at our front-end bootcamps and it has its own chapter in our new book. Together, Electron and Ember can make a powerful team. You can build desktop apps while utilizing the power of Ember’s organization and conventions as well as use modern JavaScript with Babel.

You might recognize some of these apps—they’re all built with Electron!

Setting Up Ember-Electron

  1. If you don’t have ember-cli installed yet, run npm install -g ember-cli (2.11.0-beta.4 version at writing)
  2. ember new electron-playground
  3. cd electron-playground
  4. ember install ember-electron

It will add an electron.js file to your app folder. You can modify many of the Electron specific settings and events in this file, including the default window size of your app.

mainWindow = new BrowserWindow({
    width: 800,
    height: 600
});

Since 800×600 is annoyingly small for an app, we want to change this line to:

const {width, height} = electron.screen.getPrimaryDisplay().workAreaSize;
mainWindow = new BrowserWindow({width, height});

This will detect your primary display’s size and open the app to that size.

ember-electron will also add the following to your package.json file.

"ember-electron": {
  "WHAT IS THIS?": "Please see the README.md",
  "copy-files": [
    "electron.js",
    "package.json"
  ],
  "name": null,
  "platform": null,
  "arch": null,
  "version": null,
  "app-bundle-id": null,
  "app-category-type": null,
  "app-copyright": null,
  "app-version": null,
  ...
}

These properties are for platform packaging settings for electron-packager which is used by ember-electron. I won’t go into what they do, but for a full list and other useful info visit the ember-electron repo.

One configuration step: to run your app both in a browser and on the desktop, change the locationType in your environment.js to locationType: process.env.EMBER_CLI_ELECTRON ? 'hash' : 'auto',

To launch your app in desktop mode, run: ember electron in the terminal.

Ember Inspector

You can use the developer console along with the Ember Inspector directly inside of the desktop app by hitting cmd+option+i (Mac) or ctrl+shift+i (PC)

File Management/File Viewer App

I set out to create a simple file management app that would highlight Ember’s strengths, like routing conventions and reusable components, and leverage Electron’s integration with native desktop features. It proved to be an interesting project that could easily be extended with much more functionality.

A full-featured app would support file deletion, drag-and-drop, etc. But for the sake of brevity for this blog post, we’re going to build out the base app which will allow us to navigate, view and open files. I may cover additional features in supplemental blog posts.

Bootstrapping the project

There is some groundwork we need to lay before diving into the code. Let’s create the required routes, components and utilities in one fell swoop. Run the following commands in your terminal.

ember g route application
ember g route files
ember g component nav-bar
ember g component side-bar
ember g component file-manager
ember g component file-list
ember g component folder-breadcrumbs
ember g service read-directory
ember g util format-filepath

Install the junk npm package, it will be used to filter out all junk files like “.DS_Store”, etc.

npm install --save junk

You can style the app however you want but for this example I just modified a pre-made Bootstrap theme that fits well with what we’re building. Include the Bootstrap CDN to your app/index.html.

<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">

Add the following to your app/styles/app.css file.

/*
 * Base structure
 */

/* Move down content because we have a fixed navbar that is 50px tall */

body {
  padding-top: 50px;
}

/*
 * Typography
*/

h1 {
  margin-bottom: 20px;
  padding-bottom: 9px;
  border-bottom: 1px solid #eee;
}

/*
 * Sidebar
*/

.sidebar {
  position: fixed;
  top: 98px;
  bottom: 0;
  left: 0;
  z-index: 1000;
  padding: 20px;
  overflow-x: hidden;
  overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
  border-right: 1px solid #eee;
}

/* Sidebar navigation */
.sidebar {
  padding-left: 0;
  padding-right: 0;
}

.sidebar .nav {
  margin-bottom: 20px;
}

.sidebar .nav-item {
  width: 100%;
}

.sidebar .nav-item + .nav-item {
  margin-left: 0;
}

.sidebar .nav-link {
  border-radius: 0;
}

.folder-icon {
  width: 2rem;
  height: 2rem;
  background: url('folder-icon.png') no-repeat center;
  background-size: 100% 100%;
}

.breadcrumb {
  position: fixed;
  top: 51px;
  z-index: 1000;
  width: 100%;
}

.file-list {
  margin-top: 45px;
}

The Code

We first need to create the basic layout for our app: a nav-bar, side-bar, breadcrumbs and a file viewer.

Update the app/templates/application.hbs to:

<div class="content">
  {{nav-bar}}
  <div class="container-fluid">
    {{outlet}}
  </div>
</div>

Update the app/templates/components/nav-bar.hbs to:

<nav class="navbar navbar-toggleable-md navbar-inverse bg-primary fixed-top">

  <a class="navbar-brand" href="#">{{appName}}</a>

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item">
        {{#link-to "files" "root" class="nav-link"}}File Management{{/link-to}}
      </li>
      <li class="nav-item">
        <a class="nav-link disabled" href="#">Network Monitor</a>
      </li>
    </ul>
  </div>
</nav>

You’ll notice a custom property binding in the nav-bar, {{appName}}, which should include the user’s computer username. We can define this property in the nav-bar component as a property. Add it to your app/components/nav-bar.js.

import Ember from 'ember';
const os = require('os');

export default Ember.Component.extend({
  appName: `Desktop for ${os.userInfo().username}`
});

Since Electron gives us the ability to use all of Node’s capabilities, we can use its modules directly inside our EmberJS code.

In the above code sample, we are using the os module in Node.js to retrieve information on your system’s operating system and its current logged-in user, and then displaying the username on the nav-bar.

Now let’s start working on the actual file-management page of the app. At the beginning of this section we generated an Ember route for files. We need to update its entry in the app/router.js file.

Router.map(function() {
  this.route('files', { path: '/files/:file_path' });
});

As you can see, we added a path with a parameter for :file_path. If this were an app hosted on the web, this route would be your URL path, but in this case it is primarily acting as an application state. It will keep track of the physical folder path that we are currently browsing in the app.

The beauty of this is that we are still using the conventions and magic that the EmberJS framework provides. Our code is nice and organized and we are delegating the task of routing and model retrieval to Ember.

If you run the app now, it should look something like this (with your username instead of garry).

Local username in Nav-bar

Before we set up the route, we need to add a little utility to help us create navigable breadcrumbs on top of the file-list. Update app/utils/format-filepath.js.

import Ember from 'ember';

export function formatFilePath(filePath) {
  var parts = filePath
              .replace(/\/g, '/')
              .split('/')
              .filter(Boolean);

  var link = '';
  return parts.map((part) => {
    link += `/${part}`;
    return { path: link, name: part };
  });
}

This utility breaks down a file path string and separates it into an array of objects, each with their own path and name, making it much easier later on to create navigable links with them.

Now let’s update our route to fetch the directory and file data whenever the route’s file path changes. Update app/routes/files.js.

import Ember from 'ember';
import { formatFilePath } from '../utils/format-filepath';

export default Ember.Route.extend({
  readDirectory: Ember.inject.service(),
  model(params) {
    const filePath = params.file_path === 'root' ? process.env['HOME'] : params.file_path;
    let sideBarDirectory = this.get('readDirectory').path();
    let currentDirectory = this.get('readDirectory').path(filePath);

    return Ember.RSVP.hash({
      sideBarDirectory,
      currentDirectory,
      filePath: formatFilePath(filePath)
    });
  }
});

We are injecting a service called readDirectory which we will code next, and passing it a file path to retrieve or in the case of the sideBarDirectory retrieving the default root file path. The sidebar will be like the sidebar for your native desktop file manager such as the ‘Favorites’ on Mac or ‘Quick Access’ on Windows. The folders in the sidebar are static and won’t change when you’re browsing through folders. The currentDirectory will update whenever you click on a new folder and drill down to see its contents.

Finally, we are using Ember’s RSVP promise module to forward the model to the view only when all promises in the hash have resolved.

The readDirectory service below does the heavy lifting of pulling the required information about the local files and folders as well as mapping extensions to a category and converting file size to a more human readable format. Update the app/services/read-directory.js.

import Ember from 'ember';
const fs = require('fs');
const path = require('path');
const junk = require('junk');
const Promise = Ember.RSVP.Promise;
const computed = Ember.computed;

const map = {
  'directory': ['directory'],
  'compressed': ['zip', 'rar', 'gz', '7z'],
  'text': ['txt', 'md', 'pages', ''],
  'image': ['jpg', 'jpge', 'png', 'gif', 'bmp'],
  'pdf': ['pdf'],
  'css': ['css'],
  'html': ['html'],
  'word': ['doc', 'docx'],
  'powerpoint': ['ppt', 'pptx'],
  'video': ['mkv', 'avi', 'rmvb']
};

var FileProxy = Ember.ObjectProxy.extend({
  fileType: computed('fileExt', function() {
    var ext = this.get('fileExt');
    return Object.keys(map).find(type => map[type].includes(ext));
  }),
  isDirectory: computed('fileType', function() {
    return this.get('fileType') === 'directory';
  }),
  icon: computed('fileType', function() {
    if (this.get('fileType') === 'directory') {
      return 'assets/folder-icon.png';
    }
  })
});

var humanFileSize = size => {
  var i = Math.floor( Math.log(size) / Math.log(1024) );
  return ( size / Math.pow(1024, i) ).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
}

const rootPath = process.env['HOME'];

export default Ember.Service.extend({
  path(dir = rootPath) {
    var callback = (resolve, reject) => {
      fs.readdir(dir, (error, files) => {
        if (error) {
          window.alert(error.message);
          return reject(error);
        }
        // Filter out all junk files and files that start with '.'
        var filteredFiles = files.filter(file => junk.not(file) && file[0] !== '.');
        var fileObjects = filteredFiles.map(file => {
          let filePath = path.join(dir, file);
          let fileStat = fs.statSync(filePath);
          let fileSize = fileStat.size ? humanFileSize(fileStat.size) : '';
          // Directories do not have an extension, hardcode it as 'directory'
          let fileExt = fileStat.isDirectory() ? 'directory' : path.extname(filePath).substr(1);
          let parsedPath = path.parse(filePath);

          let opts = {
            filePath,
            fileExt,
            fileSize,
            ...fileStat,
            ...parsedPath
          };
          return new FileProxy(opts);
        });
        resolve(fileObjects);
      });
    }
    return new Promise(callback);
  }
});

The map array converts some common file extensions to a category. If you want to use a more exhaustive list of extensions, the mimetypes npm package is pretty useful. But a simple map in this case will do fine. In summary, the readDirectory service uses the fs “file system” Node module to read the contents of a filepath that is passed to it. It then maps the properties of those contents to a custom FileProxy object which contains three computed properties that behave according to what type of file it is. All of this is contained within an Ember Promise that is consumed by the files route and forwarded via model to the view.

Now that we have the route and model set up with, we can start working on the templates. Add the file-manager component to the files template and pass the model to it.

{{file-manager model=model}}

Now that we have the model in the file-manager, we can start to display the data on the sidebar and file-list. Update the app/templates/components/file-manager.hbs:

{{folder-breadcrumbs filePath=model.filePath}}
<div class="row">
  {{side-bar model=model.sideBarDirectory}}
  <main class="col-sm-9 offset-sm-3 col-md-10 offset-md-2 pt-3">
    {{file-list files=model.currentDirectory}}
  </main>
</div>

This component includes the folder-breadcrumbs, sidebar and file-list components.

The breadcrumbs:

<div class="row">
  <nav class="breadcrumb">
      {{#each filePath as |pathObj|}}
        {{#link-to 'files' pathObj.path class="breadcrumb-item" }}{{pathObj.name}}{{/link-to}}
      {{/each}}
  </nav>
</div>

The sidebar:

<div class="container">
  <div class="row">
    <nav class="col-sm-3 col-md-2 hidden-xs-down bg-faded sidebar">
      <ul class="nav nav-pills flex-column">
        {{#each model as |file|}}
          {{#if file.isDirectory }}
          <li class="nav-item">
            {{#link-to "files" file.filePath class="nav-link"}}{{file.base}}{{/link-to}}
          </li>
          {{/if}}
        {{/each}}
      </ul>
    </nav>
  </div>
</div>

And finally our file-list component.

<div class="table-responsive">
  <table class="table table-striped table-hover table-sm">
    <thead class="thead-default">
      <tr>
        <th></th>
        <th>Name</th>
        <th>Type</th>
        <th>Size</th>
        <th>Last Modified</th>
      </tr>
    </thead>
    <tbody>
      {{#each files as |file|}}
      <tr>
        {{#if file.isDirectory}}
        <td><div class="folder-icon"></div></td>
        <td>{{#link-to "files" file.filePath class="nav-link"}}{{file.base}}{{/link-to}}</td>
        {{else}}
        <td></td>
        <td><a href="" {{action "openFile" file.filePath}}>{{file.base}}</a></td>
        {{/if}}
        <td>{{file.fileType}}</td>
        <td>{{file.fileSize}}</td>
        <td>{{file.mtime}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>
</div>

It loops through each file and folder and renders a table row. If it is a folder, it displays a folder icon. If it’s a file it makes the file name an anchor link with an action to open the file in whatever default application you have set for that file extension. That action is handled in app/components/file-list.js

import Ember from 'ember';
const { shell } = require('electron');

export default Ember.Component.extend({
  classNameBindings: [':file-list'],
  actions: {
    openFile(file) {
      shell.openItem(file);
    }
  }
});

This component action uses the shell module provided by Electron’s API. It provides you with some handy functions such as shell.moveItemToTrash(fullPath), shell.showItemInFolder(fullPath), and more.

That’s it! If this is your very first desktop app, congratulations! If not, then… well it’s still pretty cool, right? Your app should look something like this:

File Viewer App Screenshot

Happy coding! If you or someone you know needs an Electron and/or Ember.JS app developed, let us know! We’ll push buttons on the keyboard for you!

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project