Today we’ve just shipped a new version of the Slack Desktop application for macOS. We built it with Electron, and, as a result, it’s faster, sports a frameless look, and has a number of behind-the-scenes improvements to make for a much better Slack experience.

There are, of course, different ways to build desktop applications with web technologies. Unlike a 100% in-box approach that some other apps take, Slack takes a hybrid approach where we ship some of the assets as part of the app, but most of the assets and code are loaded remotely. Since there isn’t much information out there about how to do this with Electron, we wanted to dive into a bit more detail about how our hybrid application works.

First, Some History

Originally, the Slack desktop application was written using the MacGap v1 framework, which internally used WebView to host web content inside of a native app frame. While that served us well for a long time (including the retrofitting of multiple-team support), this architecture was starting to show its age. New features such as HTTP/2 are only coming to Apple’s new WKWebView view, and moving to this would effectively require a complete rewrite of the application. Furthermore, WebView was tied to the operating system’s version of Safari, meaning that we didn’t have many options when older versions of macOS had an issue in Safari that affected our app.

Separately, when we created the Slack Windows application, we couldn’t use the existing codebase, so we decided to bet on a brand new platform called Electron.

We’ve written about Electron before, but to summarize, Electron is a platform that combines the rendering engine from Chromium and the Node.js runtime and module system.

Since very early in the development of the Slack Electron app, we’ve had a working macOS version (albeit with many missing features). It was useful for us to be able to share our app with coworkers using macOS, for things like design feedback. So, when we looked into how to modernize the Mac app, moving to a unified codebase across Mac, Windows, and Linux was an easy choice.

Technology Stack

Despite being the first production Electron application outside of Atom, the Slack Desktop application has been kept fairly up-to-date with regards to web technologies. Our app has migrated from a CoffeeScript application written with vanilla DOM APIs to a modern ES6 + async/await React application, and we’re currently incrementally moving our app to TypeScript.

The Chromium Multi-process Model

Electron inherits Chromium’s multi-process model — the main application as well as every Slack team that you’re signed into live in a separate process with its own memory space. For us, this means that we can restart individual teams that crash or have other issues without affecting the rest of the app, as well as protection from GPU driver issues via a separate GPU process.

On macOS, these renderer processes are labeled “Slack Helper;” you’ll see one for every team, plus three extra for crash reporting, the GPU, and the process that hosts the team switcher.

The WebView Tag

While we generally trust the local Slack application to run with full access to the desktop and Node.js, allowing remote content to directly access desktop features and Node.js is insecure — if someone were to Man-In-The-Middle Slack, they would have full control over user computers! To prevent this, we use a feature of Electron ported from Chrome Apps called the WebView element (unrelated to Apple’s WebView view mentioned above). Conceptually, this HTML element is similar to an iframe, in that it includes another site inline as a block element. However, it actually creates a separate Chromium renderer process and delegates rendering of content for its hosting renderer, similar to how the Flash plugin host framework works.

Before any navigation occurs, we get a chance to run custom code with Node.js integration enabled, called a “preload script.” This script runs before the DOM is created and before the page has an origin, but gives us access to Electron and Node.js APIs.

One thing that we can do in our preload script is set a key on the window object. This allows us to expose an API to the webapp side of Slack. Since we define this API, we can set up a Security Boundary that only grants the webapp certain methods.

There are a few things that you must do in order for this approach to be secure:

  1. You must ensure that you don’t leak Node.js modules into your API surface.
  2. You should be thoughtful about your APIs, especially ones involving file paths. Make sure that a malicious caller of your API can’t access data on a user’s file-system.
  3. You only have to worry about access to JS objects via JavaScript itself, being able to see Node.js objects via the DevTools console tab is generally safe. DevTools has access to hidden V8 methods that JavaScript doesn’t, so being able to get to Node.js objects through, for example, the “closure” pseudovariable is not a concern.

Communicating between processes

Communicating between all of these different processes is Tricky Business. On top of Chromium’s low-level IPC module which lets you send messages between processes, we’ve built a library called electron-remote.

electron-remote is a pared-down, faster version of Electron’s remote module, using ES6 Proxy Objects. Using proxies, we create an object which represents the window on a remote renderer, and all method calls get sent as messages to that remote. This lets you accomplish the same things as the traditional remote module, but without the pitfalls of remote event handlers and synchronous IPC.

First, set up the API you want to create in the main window. To make our example easier to understand, we’ll use a global variable:

import {remote} from ‘electron’;
class DockHelper {
    bounceDock() {
        remote.app.dock.bounce(‘informational’);
    }
}
window.dockHelper = new DockHelper();

Next, in our preload script, we’ll actually wire it up:

import {remote} from ‘electron’;
import {createProxyForRemote} from ‘electron-remote’;
let mainWindow = createProxyForRemote(remote.getCurrentWindow());
window.desktopIntegration = {
    bounceDock: () => {
        // NB: bounceDock returns a Promise here because we’re
        // accessing it Remotely — if app.dock.bounce throws,
        // we’ll see it show up here as a rejected Promise.
        return mainWindow.dockHelper.bounceDock();
    }
}

Now, your web application has access to a new object desktopIntegration which has a bounceDock method:

window.desktopIntegration.bounceDock
 .catch((e) => console.log(`Failed to bounce dock: ${e.message}`));

Being able to access remote objects efficiently makes implementing your webapp’s API much easier. In our case, it allows us to easily send Redux App Actions to update our app’s state and by proxy, the UI that depends on that state, to render updates to the badges on the Dock icon, or to update the unreads state on the team switcher items.

You must be careful when using electron-remote to audit your remote objects the same way that you audit your other preload objects — being able to ask another process to do something malicious is just as bad as doing it in-process!

Open Source Libraries

As part of writing the Slack Desktop application, we’ve developed a number of libraries and tools that we’ve open-sourced:

We’ve also spent some time contributing to the Electron project itself, to help improve the framework for developers.

As you can see, the new Slack Desktop app helps our development team have the best of both worlds — the rapid iteration and ecosystem of web development, and the ability (with a bit of C++ and elbow grease!) to access the underlying Mac operating system in ways that websites can’t reach. We’re excited for the future of our Desktop apps, especially all the things we can do to bring together your team’s work together.

If you want to help us make Slack a little better each day, check out Careers site and apply today. Apply now