Using Capacitor to Build and Distribute an Elm App

In Web Components with Ionic 4 and Elm: Promises and Pitfalls I looked at building an Elm app using the new Ionic 4 web components. Despite some rough edges and places where the libraries didn't do an especially good job of sharing control of the DOM, the overall process turned out to be pretty painless.

Since I was already spending some time with the Ionic team's in-development tools and frameworks, I decided to take the Elm app from the last post and build it into a hybrid native app using the Ionic's new Capacitor framework.

Capacitor describes itself as "a cross-platform API and code execution layer that makes it easy to call Native SDKs from web code and to write custom Native plugins that your app might need". As you might expect given the people who developed it, Capacitor also has excellent easy-to-use tooling for managing builds and deployment platforms. It's still in beta, but is already fully capable of building apps for iOS, Android, and Electron.

Capacitor was designed with a "code once, configure everywhere" philosophy. This is in sharp contrast to Cordova, which it is intended as an alternative to (or perhaps evolution of). Where the team behind Cordova has put tremendous engineering effort into allowing it to manage the configuration and build process across every supported platform, Capacitor has instead focused on managing an application's web assets and the integration between them and the target platform, while leaving the specifics of actually building and packaging an app to the platform's own dedicated tool chain.

When I first heard that they were taking Capacitor in this direction I was a little disappointed, since not having to worry about platform differences wherever possible was one of the reasons I got into hybrid app development in the first place! However, after having used Capacitor on this project I'm a lot more firmly on board with their vision.

First of all and somewhat trivially, for a simple project like this the only real interaction you have to have with Xcode or Android Studio is starting an emulator and finding which button in the IDE interface you need to click to build and deploy the app.

Second and more importantly, the actual build experience with Cordova never quite lived up to the "write once, run everywhere" dream anyway. I've spent almost as much time managing my Cordova's config.xml file as I've saved by having all of my project configuration in one place. And while the command line build process works for the most part on Android, for iOS projects I've always wound up spending a lot of time in Xcode (if I'm lucky), or scrolling through pages of terminal output and trying to understand obscure build errors (if I'm not). So overall I was willing to grant that, at least in principle, leaving the platform-specific heavy lifting to the platform-specific tools could make for a better experience.

Capacitor Setup

Capacitor is designed to be added to existing projects rather than requiring you to generate a fresh Capacitor based project, so all we'll really need to do is install it and make a few configuration changes.

However, we'll need create-elm-app to relinquish control over package.json and other assorted configuration files. To do this, run

elm-app eject

Then install Capacitor via npm

npm install --save @capacitor/core @capacitor/cli

And initialize the Capacitor configuration by running

npx cap init

Note the beginning of that command is npx and not npm! This will run Capacitor from inside the project's local node_modules directory, allowing a different Capacitor version to be used in different projects and reducing the need for globally installed packages. Just be aware that the npx command has only been present since npm v5.2.0, so you'll need that version or a more recent one to use it.

Next change the webDir setting in capacitor.config.json from "www" to "build" to tell Capacitor where create-elm-app is putting its output.

Finally, run a build with the public assets set to go to the build directory root

PUBLIC_URL=./ npm run build

And that's it! You can make sure everything worked by running Capacitor's internal server with

npx cap serve

Building for Other Platforms

Once Capacitor is set up its remarkably easy to add new platforms. Want to try your project as a standalone application? Assuming you've already installed Electron, just run

npx cap add electron
npx cap copy

Then

cd electron
npm run electron:start

And you have a running electron app built with your web app assets!

Adding mobile platforms is similarly easy, eg

npx cap add ios

However, because of Capacitor's hands-off approach to the details of actually building and deploying your code on the platform, the next step, which is to run

npx cap open ios

will open Xcode directly, and from there you're on your own. Still, if you have your Xcode environment set up all you need to do now is select a simulator, click "Play", and you'll have a working iOS build. Not bad for only running a couple of commands.

Future Improvements

One important thing I haven't mentioned since the beginning of the last post is the CDN-based method we're using to load the Ionic components. If you were going to publish this app in a store you wouldn't want to rely on the end user's device being connected to the internet the first time the app runs!

Unfortunately, integrating Ionic into the build pipeline is a little more complicated than just installing the @ionic/core package from npm and importing it. All of the Ionic components are built with StencilJS, another product of the Ionic team and a very interesting tool in its own right. Stencil components expect to be able to load the their assets (as well as polyfills and other associated code) on the fly, so just packing up the Ionic Core components in the main Webpack bundle won't work.

Stencil has a plugin intended to help with this, stencil-wepback, but it puts all of its output in the top level of the build directory instead of the static/js folder our app is expecting to load them from. There's no configuration setting to control this at the moment, but I've submitted a PR adding one. In the meantime, I've found a more manual solution in the form of copy-webpack-plugin, which can be seen in the webpack config for the app if you're curious.

All in all this experience has given me even more confidence in the future of hybrid and universal web app development. I was able to start with a language I love, pull in my preferred mobile friendly UI framework, build the result as a PWA, and package it as a standalone app for all major platforms with a minimum of customization, hacks, or fighting with frameworks. I'll certainly be keeping a close eye on Ionic 4 and Capacitor as they get closer to release, and expect to be pitching them as technology choices for future client projects.

Tools and References