Now Available React Programming: The Big Nerd Ranch Guide
Front-End ReactBased on our React Essentials course, this book uses hands-on examples to guide you step by step through building a starter app and a complete,...
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!
npm install -g ember-cli
(2.11.0-beta.4 version at writing)ember new electron-playground
cd electron-playground
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.
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)
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.
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;
}
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:
Update the app/templates/components/nav-bar.hbs
to:
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).
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.
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
:
This component includes the folder-breadcrumbs, sidebar and file-list components.
The breadcrumbs:
The sidebar:
And finally our file-list component.
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:
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!
Based on our React Essentials course, this book uses hands-on examples to guide you step by step through building a starter app and a complete,...
Svelte is a great front-end Javascript framework that offers a unique approach to the complexity of front-end systems. It claims to differentiate itself from...
Large organizations with multiple software development departments may find themselves supporting multiple web frameworks across the organization. This can make it challenging to keep...