Deploying apps with JCEF

The Chromium Embedding Framework (CEF) makes it easy to instantiate a Chrome webview inside your C++ app. The same team also provides JCEF which brings that capability to any JVM language. Recently a customer came to us with an interesting request: how exactly do you deploy an app that uses JCEF? There are over 100 million installs of CEF around the world, and now we’ll show you how to add a few more.

tl;dr

As part of helping this customer we put together a sample app. Fork it and use it as a base, or just refer to it for guidance.

You must be using Conveyor 7.2 or above for JCEF to work.

Introduction

The right way to deploy an app that uses JCEF isn’t immediately obvious. It doesn’t bundle its own native files. Instead, starting it up will download the libraries and then unpack it to the current working directory on the fly.

That’s convenient for development, but shipping this way is a bad idea:

  • CEF won’t be uninstalled properly, so your app will leave a huge amount of “cruft”.
  • The working directory of your app might not be writeable.
  • You’ll have to provide your own download UI.
  • The CEF binaries won’t be properly signed and this can cause issues with Gatekeeper or Windows Defender.
  • You won’t benefit from any delta update capabilities of the underlying platform.

Let’s ship JCEF properly! Like always when shipping JVM apps with Conveyor we won’t need any VMs or multi-platform CI, because Conveyor can make packages for every OS from any machine.

Set up the build system

Our sample app uses Gradle but Conveyor doesn’t depend on that; you can use any build system.

Declare some variables that hold the relevant version numbers. JCEF has a fairly complicated version numbering scheme. You can get these values from the JCEF release notes in the corresponding GitHub Release (example). We’re going to use the “JCEF Maven” distribution so CEF will be downloaded automatically during development.

Start with an app that can already be packaged with Conveyor. You can use conveyor generate to make one quickly or follow a tutorial.

Next add this to your build.gradle.kts:

val jcefVersion = "110.0.25"
val jcefCommitHash = "87476e9"
val cefVersion = "$jcefVersion+g75b1c96+chromium-110.0.5481.78"

Add the dependency on JCEF:

dependencies {
    implementation("me.friwi:jcefmaven:$jcefVersion")
}

We’re going to need these version numbers in our conveyor.conf too, but repeating yourself is for the weak. Let’s export these values as config when the printConveyorConfig task is run. This task is defined by the open source Conveyor Gradle plugin and simply emits textual config extracted from the build system:

tasks.named<hydraulic.conveyor.gradle.PrintConveyorConfigTask>("printConveyorConfig") {
    doLast {
        println("jcef.ver = $jcefVersion")
        println("jcef.commit-hash = $jcefCommitHash")
        println("jcef.cef-ver = \"$cefVersion\"")
    }
}

Finally, we need to set some JVM arguments because JCEF needs access to some internal APIs:

application {
    applicationDefaultJvmArgs = listOf(
        "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
        "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
        "--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED",
        "--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
    )
}

Initialize JCEF

To start JCEF we must specify where to find the native Chromium files. We can figure that out by using the app.dir system property which is set automatically by Conveyor at packaging time.

Here’s an Apache 2 licensed Java class you can use to get a CefBuilder. Feel free to copy/paste it into your code.

/**
 * Apache 2 licensed. Feel free to copy into your codebase.
 */

package conveyor;

import me.friwi.jcefmaven.CefAppBuilder;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class JCefSetup {
    public static CefAppBuilder builder() {
        Path jcefDir = getJcefDir();
        CefAppBuilder builder = new CefAppBuilder();
        builder.setInstallDir(jcefDir.toFile());
        return builder;
    }

    private static Path getJcefDir() {
        String appDir = System.getProperty("app.dir");
        if (appDir == null) {
            // Dev mode
            return Paths.get("./jcef-bundle");
        }

        // Packaged with Conveyor
        String os = System.getProperty("os.name").toLowerCase();
        Path appDirPath = Paths.get(appDir);
        if (os.startsWith("mac")) {
            Path jcefDir = appDirPath.resolve("../Frameworks").normalize();
            if (!Files.exists(jcefDir.resolve("jcef Helper.app"))) {
                throw new IllegalStateException("jcef Helper.app not found");
            }
            return jcefDir;
        } else if (os.startsWith("windows")) {
            Path jcefDir = appDirPath.resolve("jcef");
            if (!Files.exists(jcefDir.resolve("jcef.dll"))) {
                throw new IllegalStateException("jcef.dll not found");
            }
            return jcefDir;
        } else {
            Path jcefDir = appDirPath.resolve("jcef");
            if (!Files.exists(jcefDir.resolve("libjcef.so"))) {
                throw new IllegalStateException("libjcef.so not found");
            }
            return jcefDir;
        }
    }
}

If the app is running from the IDE or command line the default behavior is used of downloading CEF and dumping it into the current working directory (probably the root of your project tree). Otherwise, we figure out where the files are in the app’s install directory.

What follows is some generic CEF setup code which we don’t go into here. There are extensive comments and you can just copy it if you’re new to CEF.

The last step is to pack the web view into a Swing window. You can also use CEF with JetPack Compose. If you’re working with JavaFX on the other hand you don’t need CEF, because JavaFX comes with a WebKit based browser control out of the box.

Deployment config

Now for conveyor.conf. We start by importing things from our Gradle build like the CEF version numbers, the JVM arguments we added earlier, the classpath and more. Then we compute some complicated-looking URLs. The keys in the jcef object are ignored by Conveyor, they’re just for using them in substitutions:

include required("#!./gradlew -q printConveyorConfig")

jcef {
    releases = "https://github.com/jcefmaven/jcefmaven/releases/download/"

    windows.amd64 = "zip:"${jcef.releases}${jcef.ver}"/jcef-natives-windows-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".jar!/jcef-natives-windows-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".tar.gz"
    mac.amd64   = "zip:"${jcef.releases}${jcef.ver}"/jcef-natives-macosx-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".jar!/jcef-natives-macosx-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".tar.gz"
    mac.aarch64 = "zip:"${jcef.releases}${jcef.ver}"/jcef-natives-macosx-arm64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".jar!/jcef-natives-macosx-arm64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".tar.gz"
    linux.amd64.glibc = "zip:"${jcef.releases}${jcef.ver}"/jcef-natives-linux-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".jar!/jcef-natives-linux-amd64-jcef-"${jcef.commit-hash}"+cef-"${jcef.cef-ver}".tar.gz"
}

We’re computing some scary looking URLs here. JCEF hides the native files we need inside a tarball, which is then wrapped inside a jar. Conveyor can handle that! We just have to use this syntax: zip:https://example.com/foo.zip!/path/in/zip It’ll download the file you specify from inside the remote zip file, then extract it to get the files inside. This works for every OS, so we don’t need to cross-compile to get working packages.

Now we can define the inputs that import the CEF native files. Let’s start with Windows and Linux:

app {
  windows {
    amd64.inputs += ${jcef.windows.amd64} -> jcef

    inputs += {
      content = "."
      to = jcef/install.lock
    }
  }
  
  linux {
    amd64.glibc.inputs += ${jcef.linux.amd64.glibc} -> jcef

    inputs += {
      content = "."
      to = jcef/install.lock
    }
  }
}

We start by importing the native files from the remote URL and dropping them into a subdirectory named jcef. At install time this will be in turn under a directory named app, but we don’t need to care about the exact location here.

Then we add another input object that creates a file from scratch named jcef/install.lock. The contents of this file don’t matter, only its existence, so we just use a period (you can’t create entirely empty files this way). JCEF uses the presence of this file to decide if the native files were “installed” or whether it should download them.

For macOS the config is similar in spirit, but uses different file locations. We have to put the native files under the Contents/Frameworks directory instead of next to the app’s JARs:

app {
  mac {
    amd64.bundle-extras += {
      from = ${jcef.mac.amd64}
      to = Frameworks
    }
    aarch64.bundle-extras += {
      from = ${jcef.mac.aarch64}
      to = Frameworks
    }

    bundle-extras += {
      content = "."
      to = Frameworks/install.lock
    }
  }
}

Finally, we can set some extra Info.plist metadata entries for the Mac app to improve how Chromium operates. These are taken from what Electron uses.

Done

That’s it! Now when we run conveyor make site or any other build task Conveyor will download Chromium for each target machine, extract the files, combine them with your app and ensure they’re properly signed and notarized. The resulting app will then use that bundled CEF at runtime.

.