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 ("me.friwi:jcefmaven:$jcefVersion")
implementation}
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:
.named<hydraulic.conveyor.gradle.PrintConveyorConfigTask>("printConveyorConfig") {
tasks{
doLast ("jcef.ver = $jcefVersion")
println("jcef.commit-hash = $jcefCommitHash")
println("jcef.cef-ver = \"$cefVersion\"")
println}
}
Finally, we need to set some JVM arguments because JCEF needs access to some internal APIs:
{
application = listOf(
applicationDefaultJvmArgs "--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() {
= getJcefDir();
Path jcefDir = new CefAppBuilder();
CefAppBuilder builder .setInstallDir(jcefDir.toFile());
builderreturn 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();
= Paths.get(appDir);
Path appDirPath if (os.startsWith("mac")) {
= appDirPath.resolve("../Frameworks").normalize();
Path jcefDir if (!Files.exists(jcefDir.resolve("jcef Helper.app"))) {
throw new IllegalStateException("jcef Helper.app not found");
}
return jcefDir;
} else if (os.startsWith("windows")) {
= appDirPath.resolve("jcef");
Path jcefDir if (!Files.exists(jcefDir.resolve("jcef.dll"))) {
throw new IllegalStateException("jcef.dll not found");
}
return jcefDir;
} else {
= appDirPath.resolve("jcef");
Path jcefDir 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.