Open a native window, load any web page, and talk to it from Java.
Your code and your page run in one process, with no HTTP server in between.
~32 MB packaged app with its own Java runtime | 0 lines of native code to build | 1 jar for Windows and Linux | any frontend
Application app = Application.create(AppOptions.of("com.example.notes"));
app.run(() -> {
WebWindow window = app.createWindow(WindowOptions.defaults().withTitle("Notes").withSize(900, 600));
window.onMessage(text -> window.postMessage("You said: " + text)); // page -> Java -> page
window.loadHtml("<button onclick=\"jolt.postMessage('hi')\">Say hi</button>");
window.eval("1 + 2").thenAccept(json -> System.out.println(json)); // prints 3
});That is a complete app. A native window, a real web view, two-way messages, and a script round trip, in eight lines of Java.
+--------------------------- one process ----------------------------+
| Your Java code The window |
| (files, databases, network, logic) <-> a web page (HTML/CSS/JS) |
| window.postMessage / onMessage |
+---------------------------------------------------------------------+
The page draws the interface and handles clicks. Java does everything a web page is not allowed to do: read and write files, talk to a database, call native libraries, keep long-running state.
- Tiny apps. There is no bundled browser. Jolt uses the web view your operating system already has (WebView2 on Windows, WebKitGTK on Linux), so a packaged app with its own Java runtime is about 32 MB.
- No native build step. Jolt is built on Java 25's Foreign Function & Memory API. No JNI, no C toolchain, nothing to
compile. The only binary shipped is Microsoft's redistributable
WebView2Loader.dll. - Bring any frontend. Plain HTML, React, Vue, Svelte: anything that builds to a web page. The repo includes a React, TypeScript and Tailwind example with hot reload.
- Bring any Java library. Your backend is plain Java. Use SQLite, H2, an HTTP client, or a native library the usual way.
- One jar, two platforms. Windows 10/11 and Linux (X11 and Wayland), from the same dependency list.
- Secure by default. Pages can only reach your Java code if you trust them, and you can change the rules at any time. See Security.
- Thread safe. Every
WebWindowandApplicationmethod can be called from any thread.
| Jolt | Electron | Tauri | JavaFX WebView | |
|---|---|---|---|---|
| Backend language | Java | JavaScript (Node.js) | Rust | Java |
| Ships its own browser | No, uses the system web view | Yes, Chromium | No, uses the system web view | Yes, a WebKit build inside JavaFX |
| Native toolchain to build | None | None | Rust | None |
| Use your Java libraries directly | Yes | No | No | Yes |
You need JDK 25 and Maven 3.9 or newer. On Linux, also sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0.
git clone https://github.com/B077AS/jolt.git
cd jolt
mvn install -DskipTestsThen run the smallest example, a window with a scripted round trip:
$r = (Get-Location).Path
java --enable-native-access=ALL-UNNAMED `
-cp "$r\examples\hello-window\target\classes;$r\jolt-core\target\classes;$r\jolt-win32\target\classes;$r\jolt-linux\target\classes" `
io.github.b077as.jolt.examples.hello.HelloWindowWant a real app? The notes example is a complete small program with a Java data layer, JSON requests between Java and the page, light and dark themes, and an optional React frontend.
Status: early. Both backends pass the same shared integration suite (Windows, and Linux on X11 and Wayland in containers). macOS is not supported. If you try Jolt and something is rough, please open an issue. It helps a lot.
Jolt is published on Maven Central under the group id io.github.b077as. Add the API and both backends. Each backend
only activates on its own operating system, so one jar runs on both.
<properties>
<maven.compiler.release>25</maven.compiler.release>
<jolt.version>0.2.4</jolt.version>
</properties>
<dependencies>
<dependency>
<groupId>io.github.b077as</groupId>
<artifactId>jolt-core</artifactId>
<version>${jolt.version}</version>
</dependency>
<dependency>
<groupId>io.github.b077as</groupId>
<artifactId>jolt-win32</artifactId>
<version>${jolt.version}</version>
</dependency>
<dependency>
<groupId>io.github.b077as</groupId>
<artifactId>jolt-linux</artifactId>
<version>${jolt.version}</version>
</dependency>
</dependencies>Java prints a warning on every start unless native access is enabled. Put it in your jar manifest so it is always on:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifestEntries>
<Enable-Native-Access>ALL-UNNAMED</Enable-Native-Access>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>Or pass --enable-native-access=ALL-UNNAMED on the java command line.
dependencies {
implementation("io.github.b077as:jolt-core:0.2.4")
implementation("io.github.b077as:jolt-win32:0.2.4")
implementation("io.github.b077as:jolt-linux:0.2.4")
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}If you only target one operating system, depend on jolt-core and that system's backend only.
Building from source or using a version that is not on Maven Central yet? Run
mvn install -DskipTestsin this repository and use0.1.0-SNAPSHOTas the version. It is installed into your local Maven repository.
- What works today
- Requirements
- Your first window
- Talking between the page and Java
- The frontend
- Using what Java is good at
- Run it
- Window icon, title bar and colours
- Change the app icon
- Package it
- Things to know
- Multi-file pages, request handlers and uploads
- Security
- Saving passwords and tokens
- Layout of a Jolt app
- When something goes wrong
- Build Jolt itself and run the examples
- How it works
- Windows (WebView2) and Linux (WebKitGTK 4.1, X11 or Wayland chosen by GTK at runtime). Several windows at once.
loadUrl,loadHtml,eval(returns the JSON result) andaddInitScript.loadResources: your page as separate HTML, CSS and JS files (or a bundler's output) served from inside the web view with no socket, with a stable origin solocalStoragesurvives restarts. See Multi-file pages, request handlers and uploads.- Java request handlers for the page's own requests (
fetch, forms, htmx, JSON APIs), aRouterwith path parameters, groups, filters and error handling,multipart/form-datauploads with a configurable size limit, and redirects. Works with any front end; htmx is optional. - String messages in both directions (
window.jolt.postMessageandonMessagein the page), and binary channels for large or raw data such as video frames (since 0.2.3). See Binary data: channels. - Window icon (PNG, or a multi-size
.icofor crisp taskbar icons), light or dark native title bar, exact title bar colours (Windows 11) and the web view's background colour, set at creation or later. - The operating system's open, save and choose-folder dialogs, which give your Java code the real path. See Choosing files.
- Every
WebWindowandApplicationmethod is safe to call from any thread. - Developer tools (right-click, Inspect) for debugging the page.
- Packaging into a double-clickable app that bundles its own Java runtime.
jolt-credentials(since 0.2.1, optional): keep passwords and tokens in the Windows Credential Manager or the Linux Secret Service. See Saving passwords and tokens.- Start maximized or centered, hide the window until the page is ready, and maximize or restore it later (since 0.2.2). See Opening maximized, centered, and without a flash.
See the changelog for what each version added.
| JDK 25 | Jolt uses the Foreign Function & Memory API (final since Java 22). |
| Maven 3.9+ or Gradle | Any build tool that can use Maven Central. See Add Jolt to your project. |
| Windows 10/11 | With the WebView2 Runtime (preinstalled on Windows 11). |
| Linux | GTK 3 and WebKitGTK 4.1: sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0 on Debian/Ubuntu 22.04+ (not the older 4.0 API). |
Run Java with --enable-native-access=ALL-UNNAMED, or put Enable-Native-Access: ALL-UNNAMED in your jar manifest.
Without it Java prints a warning on every start.
loadResources serves your files from inside the web view on WebKitGTK 2.40 or newer; an older WebKitGTK falls back to a
guarded loopback server automatically. See Requirements and the loopback fallback.
import io.github.b077as.jolt.AppOptions;
import io.github.b077as.jolt.Application;
import io.github.b077as.jolt.WebWindow;
import io.github.b077as.jolt.WindowOptions;
public final class Main {
public static void main(String[] args) {
Application app = Application.create(AppOptions.of("com.example.hello"));
try {
app.run(() -> {
WebWindow window = app.createWindow(WindowOptions.defaults().withTitle("Hello").withSize(600, 400));
window.onMessage(text -> window.postMessage("Java received: " + text));
window.loadHtml("""
<h1>Hello</h1>
<button onclick="jolt.postMessage('ping')">Ping Java</button>
<p id="out"></p>
<script>jolt.onMessage(m => document.getElementById('out').textContent = m)</script>
""");
});
} finally {
app.close();
}
}
}Click the button: the page sends ping to Java, Java answers, and the page shows the answer. That round trip is the whole
mechanism. Everything else in this README is structure around it.
How it fits together:
AppOptions.of("com.example.hello"): an identifier for your app. Jolt uses it to choose a per-user data folder (%LOCALAPPDATA%\com.example.helloon Windows,~/.local/share/com.example.helloon Linux) where the web view keeps cookies and storage. Letters, digits,.,_and-only.app.run(onStart): starts the window system and blocks until the app ends (last window closed, orquit()). Create your windows insideonStart.WebWindow: one window with a web view. You can create several.window.jolt: injected into every page automatically.jolt.postMessage(text)sends a string to Java;jolt.onMessage(callback)receives strings from Java.
Messages are plain strings, in both directions:
| Java | Page | |
|---|---|---|
| send | window.postMessage(String) |
jolt.postMessage(string) |
| receive | window.onMessage(listener) |
jolt.onMessage(callback) |
| run a script, get the result | window.eval(String) returns a CompletableFuture<String> of the JSON result |
Strings of any content work (quotes, newlines, emoji, megabytes). Messages arrive in the order they were sent.
window.addInitScript(String) runs a script in every page before the page's own scripts. Use it to hand the page values it
needs on its very first frame, for example window.addInitScript("window.__SETTINGS__ = {...}").
A real app wants to call a Java function from the page and await the answer. Build that on the string channel with a tiny
protocol: the page sends {"id": 1, "method": "notes.add", "params": {...}} and Java replies {"id": 1, "result": ...} or
{"id": 1, "error": "..."}.
The notes example contains both halves, about 40 lines each:
-
Java (
Rpc.java): register handlers by name and the class does the rest.new Rpc(window) .on("notes.list", params -> store.list()) .on("notes.add", params -> store.add(params.get("text").getAsString())) .on("notes.delete", params -> Map.of("deleted", store.delete(params.get("id").getAsLong())));
-
Page (
ui/index.html): turn it into promises.const note = await call('notes.add', { text: 'Buy milk' }); const all = await call('notes.list');
If a handler throws, the page's call(...) rejects with the error message, so you handle failures with normal
try/catch. Generated, typed bindings are not part of Jolt, so copy this small pattern into your project. Rpc.java works
with any JSON library; the example uses Gson, which is the example's own choice.
Message listeners run on the UI thread. Anything slow you do there freezes the window. That is why Rpc.java hands
every request to a worker (a virtual thread) and replies from there. postMessage, eval and every other window method are
safe to call from any thread. The rule:
Never block the UI thread waiting for something that needs the UI thread. In particular, do not call
.get()or.join()on the result ofevalfrom a message listener.
Available since 0.2.3.
postMessage carries strings, which is right for commands and small JSON. For anything big or binary (video frames, audio,
images, a file, sensor readings) open a channel: a named, two-way pipe that carries raw bytes, with no base64 and no JSON.
BinaryChannel frames = window.openChannel("frames"); // open it before the page loads
frames.onMessage(bytes -> handle(bytes)); // from the page; the ByteBuffer is yours to keep
frames.send(ByteBuffer.wrap(pixels)); // to the page; returns false if it could not be acceptedconst frames = jolt.channel("frames");
frames.onMessage((data) => { /* data is an ArrayBuffer */ });
frames.send(new Uint8Array([1, 2, 3])); // ArrayBuffer, typed array, or a string (sent as UTF-8)-
The page connects by itself and reconnects if the connection drops, for example when it reloads.
onOpenandonClose(on both sides) tell you when.isOpen()says whether a page is connected right now. -
A slow page never blocks Java.
sendcopies the bytes, queues them and returns at once, so it is safe to call from a thread that must not wait (a video callback, say) and to reuse your buffer straight away. What happens when the page cannot keep up is your choice, per channel:window.openChannel("video", ChannelOptions.newestOnly()); // a message not sent yet is replaced by the next one window.openChannel("log", ChannelOptions.defaults().withMaxQueuedBytes(1 << 20)); // keep order; send() returns false when 1 MB is waiting
Use
newestOnlyfor live data where an old frame is worth nothing, and the default for data where every message counts. -
Callbacks run on a thread of their own, one message at a time and in order. They are not the UI thread: use
app.dispatch(...)to get there. -
Names are 1 to 128 letters, digits or
. _ : -, and one name can be open once per window.channel.close()frees it. -
Messages from the page are limited to 64 MB by default (
ChannelOptions.withMaxMessageBytes); a bigger one closes the connection.
Under the hood a channel is a WebSocket to a server on 127.0.0.1 that belongs to the window. Any program on the machine, and
any web page in the user's browser, can reach a loopback port, so a connection is only accepted when its Origin is one the
window's SecurityPolicy trusts (checked when it connects, so setSecurity takes effect), it carries a random
per-run secret that only the page's own script has, and a channel of that name is open. Your page does not see any of this. The
transport is an implementation detail and may change to something faster on a platform without changing the API.
Checked so far: the server against a plain WebSocket client (sizes from 0 bytes to 2 MB, fragmented messages, refused origins and secrets, back-pressure), and a real page that sends 256 KB through Java and back and reconnects after a reload, on Windows (WebView2) and on Linux (WebKitGTK 2.52 under X11, in Docker). Throughput has not been measured.
On Linux, use loadResources for a page that opens channels, not loadHtml. WebKitGTK refuses a ws:// connection from
the https://jolt.localhost document that loadHtml creates (mixed content), before it reaches the network. Pages served by
loadResources (the jolt://app origin, or the loopback fallback) can open one; this was checked by a probe. On Windows both work.
A web page can open a file picker (<input type="file">) but only ever sees the file's contents, never its path, and it cannot
choose where a file is saved. For that, ask the operating system's own dialogs through the window
(available since 0.2.4):
Dialogs dialogs = window.dialogs();
// Save: the user picks the place, your Java code writes the file
dialogs.saveFile(FileDialogOptions.defaults()
.withTitle("Export report")
.withFileName("report.pdf")
.withFilters(FileFilter.of("PDF documents", "pdf")))
.thenAccept(chosen -> chosen.ifPresent(path -> writeReport(path))); // empty when the user cancels
// Open: you get the real path
dialogs.openFile(FileDialogOptions.defaults().withFilters(FileFilter.of("Images", "png", "jpg", "jpeg")));
dialogs.openFiles(FileDialogOptions.defaults()); // several files; an empty list when cancelled
dialogs.chooseFolder(FileDialogOptions.defaults());- Every method returns a
CompletableFutureat once and may be called from any thread, for example from a request handler. The future completes when the user has chosen or cancelled. Cancelling is not an error: you get an empty result. - The dialog is modal to the window, which keeps working underneath (the page keeps running). A second request while one is open waits for the first.
FileDialogOptionsis all optional:withTitle,withInitialDirectory,withFileName(the proposed name in a save dialog; in an open dialog the file that is already chosen, so the user only confirms) andwithFilters(the first filter starts selected; no filters means all files) andwithSize(width, height)(see below).- Size.
FileDialogOptions.defaults().withSize(1000, 650)asks for a bigger dialog (logical pixels;0leaves that side to the system). The system owns the dialog, so it is a request. On Windows the dialog is resized right after it opens, centred where it appeared and kept on the screen, and Windows then remembers that size for later dialogs. Linux cannot size the desktop portal's chooser, so a dialog with a size is GTK's own chooser window, which does not go through the portal; GTK will not make it smaller than its contents need. saveFiledoes not create the file, and the system asks before overwriting one that exists. Write the file only if the result is present.- Windows uses the common item dialogs. Linux uses
GtkFileChooserNative, which goes through the desktop portal when the app is sandboxed (Flatpak, Snap) and otherwise shows GTK's own dialog. On Linux the filters match the extension in lower and upper case only (.pngand.PNG, not.Png). - If the window is closed while a dialog is open, the dialog is closed first and the request fails with
WindowClosedException.
Checked with the real dialogs, answered by tests: cancel, confirm in a save dialog, confirm a preselected file in an open dialog, requests queued behind each other, and closing the window with a dialog open. That ran on Windows and on Linux under X11 and Wayland (in Docker, where the portal is not present, so the GTK dialog was used). Not checked: the desktop portal itself, several monitors, and a user actually clicking through a dialog.
A page cannot choose where a file goes, and it never sees a path. To let it save a file the user picks, enable saveAs on the
window (before the page loads) and call jolt.saveAs from the page:
window.enableSaveAs(); // or window.enableSaveAs(Path.of(System.getProperty("user.home"), "Documents")) to choose the starting folderconst saved = await jolt.saveAs(blobOrBytesOrText, {
name: "report.pdf", // proposed file name
title: "Export report",
filters: [{ name: "PDF documents", extensions: ["pdf"] }],
});
if (saved) console.log(saved.name, saved.size); // { name, size }: the name the user chose, never the path
else console.log("cancelled"); // null when the user closes the dialogdatacan be a string (written as UTF-8), anArrayBuffer, any typed array, or aBlob. It is sent after the user has chosen a file, in 1 MB pieces, so a largeBlobis not read if they cancel and its size is not limited by memory.- The file is written to a temporary file next to the target and moved into place when the last byte has arrived. A transfer that fails, or a page that goes away halfway, never leaves a half-written file where the user asked for a whole one.
- The promise rejects with an
Errorif the user's chosen location cannot be written, if the connection to Java is lost, or ifenableSaveAs()was not called. It rejects with aTypeErrorfor data it cannot write. - Only pages whose origin the window's
SecurityPolicytrusts can use it. It runs over a binary channel, so the Linux note there applies: show the page withloadResources, notloadHtml. The channel namejolt.saveAsis reserved;openChannelrefuses names that start withjolt..
Your page is normal HTML, CSS and JavaScript. Use plain files or any framework. The web view is a current Chromium (Windows) or WebKit (Linux), so write for both.
Run your frontend's dev server (Vite, webpack, anything) and point the window at it:
window.setSecurity(window.security().withTrustedOrigins("http://localhost:5173")); // see Security
window.loadUrl("http://localhost:5173");The notes example does this when started with -Dui.url=.... Hot reload is your dev server's job and the Java side does
not change. With a real Vite dev server, editing a React component updates the open Jolt window in place, without
reloading the page. Without a dev server, window.loadResources(AppResources.directory(Path.of("src/main/resources/ui")))
serves the folder straight from disk, reading each file again on every request, so reloading the page picks up your edits.
Put the finished page in src/main/resources/ui and serve it from the jar. With separate HTML, CSS and JS files (plain files
or the folder a bundler such as Vite produces), use loadResources:
window.loadResources(AppResources.classpath("ui")); // ui/index.html, ui/css/..., ui/js/...The files stay separate, relative URLs and ES modules work, and the page keeps a stable origin so localStorage survives
restarts. See Multi-file pages, request handlers and uploads, which also covers
answering the page's requests from Java.
If you prefer one self-contained HTML file (scripts and styles inlined, which Vite produces with vite-plugin-singlefile and the
React example uses), loadHtml still works:
String html = new String(MyApp.class.getResourceAsStream("/ui/index.html").readAllBytes(), StandardCharsets.UTF_8);
window.loadHtml(html);Large pages are fine on both platforms (tested with a 3 MB document). On Windows, Jolt serves every loadHtml page from memory
under its own address, https://jolt.localhost, so there is no size limit and nothing for you to do.
examples/notes-app/frontend/ is the same notes app with a modern interface: React + TypeScript, built with Vite,
styled with Tailwind CSS, with Lucide icons and Motion animations. It talks to the unchanged Java backend; the
Java side does not know or care which frontend it is showing. The plain-HTML page in src/main/resources/ui is the same
app again in hand-written CSS with no framework, so you can compare the two. Both have a light, a dark and a "follow the
system" theme, and both drive the native title bar to match.
Run these from examples/notes-app/frontend (build the Java app first, see Run it). PowerShell splits -D
flags at the first dot, so keep the quotes around them:
npm install
# development, with hot reload
npm run dev # Vite on http://127.0.0.1:5173
java --enable-native-access=ALL-UNNAMED "-Dui.url=http://127.0.0.1:5173" `
-cp "..\target\package\*" io.github.b077as.jolt.examples.notes.NotesApp
# a build to run or ship
npm run build # typechecks, then writes dist\index.html (one file)
java --enable-native-access=ALL-UNNAMED "-Dui.file=dist\index.html" `
-cp "..\target\package\*" io.github.b077as.jolt.examples.notes.NotesAppTo ship it, copy dist\index.html over src\main\resources\ui\index.html and rebuild the Java app. The page then lives
inside the jar and no flags are needed.
What the example contains, and what to copy into your own project:
| File | Purpose |
|---|---|
src/jolt.ts |
The typed bridge: declares window.jolt for TypeScript and provides call<T>(method, params), which returns a promise. This is the only file that knows about Jolt. |
src/api.ts |
One typed function per Java handler (api.list(), api.add(text) and so on). Rename a handler in Java and TypeScript tells you where to update. |
src/theme.ts |
The light, dark or system theme: remembers the choice through Java and tells Java the colours so the native title bar matches. |
src/useNotes.ts |
The notes and the last error, kept in sync with Java. |
src/App.tsx, src/components/ |
Ordinary React components. They call api.*; nothing in them is Jolt-specific. |
src/styles.css |
Tailwind setup and the two colour themes as variables. |
src/smoke.ts |
A self-test the Java side can trigger (--smoke), so the real page is checked against the real backend. |
vite.config.ts |
Adds Tailwind and vite-plugin-singlefile, which inlines scripts and styles into index.html. |
The example was built with React 19, TypeScript 7, Vite 8 and Tailwind 4. The built page passes its self-test against the Java backend on Windows and on Linux under X11 and Wayland. Vue, Svelte, Solid and plain TypeScript go through the same Vite setup and produce the same kind of page, but only React has been run.
src/api.ts is written by hand and must match the handlers in NotesApp.java. If the page is opened in an ordinary browser
(outside Jolt), window.jolt does not exist; call() then rejects with a clear message instead of crashing.
Because your backend is just Java, use any library. The example's store (NotesStore.java) reads and writes a JSON file in
the app's data folder, atomically:
Path dataDir = AppOptions.of(APP_ID).dataDirectory(); // per-user, per-app folder
NotesStore store = new NotesStore(dataDir.resolve("notes.json"));The same applies to databases (SQLite, H2), HTTP clients, native libraries and background jobs: they live in Java, and the page asks for them through your handlers.
During development, just run your main. For the notes example, from the root of this repository:
mvn -pl examples/notes-app -am install -DskipTests # builds it and copies the app and all its jars to examples/notes-app/target/package
java --enable-native-access=ALL-UNNAMED -cp "examples/notes-app/target/package/*" io.github.b077as.jolt.examples.notes.NotesAppAdd --smoke to run its automatic self-check instead (it exercises every request and exits 0 on success).
Run it from the repository root, because the class path is relative. From another folder, use the full path to
examples/notes-app/target/package.
Developer tools: create the window with WindowOptions.defaults().withDevTools(true), then right-click the page and
choose Inspect, or call window.openDevTools(). Leave them off in released builds.
These are drawn by the operating system, not your page, so the page cannot style them with CSS. Jolt lets you set them from Java:
WindowOptions options = WindowOptions.defaults()
.withIcon(iconBytes) // an .ico file with several sizes (best), or PNG data (256x256+)
.withTitleBarScheme(ColorScheme.DARK) // the OS draws the title bar dark (or LIGHT)
.withBackgroundColor(0x0F1117); // shown behind the page until it paints: no white flash on dark pages
WebWindow window = app.createWindow(options);
// and later, from any thread:
window.setIcon(otherPng);
window.setTitleBarScheme(ColorScheme.LIGHT);
window.setTitleBarColors(0xF4F5FA, 0x1A1D29); // exact title bar background and text colour (Windows 11 only)
window.setBackgroundColor(0xF4F5FA);Setting them in WindowOptions means the window is created that way, so it never appears in the wrong colours first.
| Windows 11 | Windows 10 | Linux, X11 | Linux, Wayland | |
|---|---|---|---|---|
| icon | yes | yes | yes | stored, but not shown: a Wayland compositor takes the icon from the app's desktop entry |
| title bar light or dark | yes | yes (version 2004 or later) | follows GTK's dark preference, when the window manager draws the title bar | same, depends on the compositor |
| exact title bar colours | yes | no (ignored) | no (ignored) | no (ignored) |
| background behind the page | yes | yes | yes | yes |
On Windows 11 the window was checked by eye (icon, dark and light title bar, and exact colours that blend into the page) and by tests that read back what Windows holds. On Linux, tests read back what GTK holds under X11 and Wayland. Windows 10 and the look of a real Linux desktop's title bar are not tested. The Linux dark preference is process-wide.
Available since 0.2.2. New windows are centered by default from that version; earlier versions let the operating system place them.
WindowOptions options = WindowOptions.defaults()
.withSize(1200, 780)
.withBackgroundColor(0x0F1117) // the page's background: painted behind the web view from the first frame
.withMaximized(true) // start maximized (false by default)
.withCentered(true) // the default: start in the middle of the screen (ignored when maximized)
.withVisible(false); // do not show the window until you say so
WebWindow window = app.createWindow(options);
window.onMessage(m -> { if (m.equals("ready")) window.show(); }); // the page sends "ready" once its content is on screen
window.loadResources(AppResources.classpath("ui"));The web view needs a second or two to start. A window that is visible during that time shows an empty body, first the
window's own background and then whatever the web view paints before the page does. Creating the window hidden and calling
show() once the page is ready (the page tells you with window.jolt.postMessage) removes that: the window appears with
its content already drawn. show() also works for a window that was created hidden and shown much later; the page
starts rendering as soon as it appears.
WindowOptions.withMinSize(width, height) sets the smallest size the user can drag the window to (client-area logical
pixels; 0 means no limit). Available since 0.2.3. Checked by tests that resize a window below its minimum on Windows and on Linux
under X11; a Wayland compositor decides window sizes itself, so it was not checked there.
window.maximize(), window.restore() and window.isMaximized() do the same later, from any thread. Maximizing a hidden
window does not show it: the window is maximized when it is first shown.
| Windows | Linux, X11 | Linux, Wayland | |
|---|---|---|---|
withCentered |
middle of the monitor's usable area (without the taskbar); a window bigger than that keeps its top-left corner on screen | middle of the screen | ignored: the compositor places windows, an application cannot |
withMaximized, maximize(), restore() |
yes | needs a window manager (a bare X server has none, so nothing happens) | yes, if the compositor allows it |
| window painted with the background colour before the web view starts | yes | the web view's colour is set as before | same |
Checked by tests that read back where Windows put the window and what it painted, by a Windows run of a real app (it opened centered or maximized, and with a hidden start no white or black frame was seen), and by the shared contract tests under X11 and Wayland in Docker. Centering was read back from GTK under X11 only. Not tested: several monitors, Windows 10, and a real Linux desktop's window manager.
The page decides when the theme changes (a switch the user clicks, or the system theme changing) but cannot restyle the window itself. It asks Java through a handler, exactly like any other request. The notes example does this:
// page: after the user picks a theme
await call('window.setTheme', { preference: 'dark', scheme: 'dark', background: '#0f1117', text: '#e7e9ee' });// Java: validate, apply, and remember
.on("window.setTheme", params -> {
ThemeRequest theme = ThemeRequest.parse(params); // rejects anything that is not exactly what we expect
window.setTitleBarScheme(theme.scheme());
window.setTitleBarColors(theme.background(), theme.text());
window.setBackgroundColor(theme.background());
settings.save(...); // so the next start is already in the right colours
return Map.of("ok", true);
})Validate what the page sends (ThemeRequest checks the values are exactly light or dark and #rrggbb); the page is
untrusted input. The example also remembers the choice in a small settings file, creates the next window with it, and passes
it to the page before any script runs (window.addInitScript("window.__JOLT_PREFERENCE__ = ...")), so the very first frame
is already correct, with no flash of the wrong theme.
A frameless window whose top strip is your own HTML, with your own minimise, maximise and close buttons, is not available
yet. For now, setTitleBarColors on Windows 11, or the light and dark schemes elsewhere, give a native title bar that
matches your page.
The icon is the picture in the title bar, the taskbar and Alt+Tab. In the notes example it is
examples/notes-app/src/main/resources/icon.ico, loaded at start-up with:
WindowOptions.defaults().withIcon(readBundledBytes("/icon.ico"))Windows asks for the icon at several small sizes: 16 px in the title bar, and 32 px or more in the taskbar and Alt+Tab,
depending on screen scaling. If you give it a single 256 px PNG it has to shrink it itself, and the result is jagged and
grainy, especially for thin strokes. An .ico file holds the same icon pre-drawn at several sizes. Jolt hands Windows the
image whose size matches, so nothing is scaled and every size is smooth. On Linux the largest PNG inside the .ico is used,
so one file serves both systems. A plain PNG still works, with the grainy-at-small-sizes caveat.
-
Make an
.icothat contains at least 16, 24, 32, 48 and 256 px images. Any icon editor or converter that exports a multi-size.icoworks. Better still, export each size from your vector artwork instead of shrinking one bitmap, so small sizes stay sharp. Classic bitmap-style entries are fine too (Jolt converts them internally). -
Replace the file. Put it in your project's
src/main/resources(the example usesicon.ico), overwriting the old one, or pointreadBundledBytesat a new name. -
Update the logo in your page, if it shows one, so the page header matches the window icon. The example pages use an inline SVG:
src/components/Logo.tsxin the React project and the<svg class="logo">block inui/index.html. -
Rebuild so the new files reach the app:
cd examples\notes-app\frontend npm run build # React page: rewrites dist\index.html copy dist\index.html ..\src\main\resources\ui\index.html # only if you ship the React interface cd ..\..\.. mvn -pl examples/notes-app -am install -DskipTests
-
Run it again. Windows caches taskbar icons, so if the old one still shows, unpin and re-pin the app or restart Explorer.
To change the icon at run time instead, call window.setIcon(bytes) from any thread.
- Use one bold shape. Avoid hairlines and detail finer than about 1/8 of the icon.
- Keep the gaps (the inside of a hook, the space under a bar) at least as wide as the lines are thick, or they fill in.
- Look at it at 16, 24 and 32 px, on a light and a dark background, before you ship it.
A page that styles every svg with a stroke (common for line-icon sets) gives an inline SVG logo a stroke too, which draws
as a thin border around its tile. Give the logo stroke: none. If it has a soft shadow, also give it overflow: visible,
because an SVG clips anything outside its own box and cuts the shadow off in a hard edge.
jpackage (part of the JDK) turns your jars into a double-clickable app that bundles its own Java runtime, so users
install nothing but the system web view.
mvn -pl examples/notes-app -am install
jpackage --type app-image --name JoltNotes `
--input examples\notes-app\target\package `
--main-jar notes-app-0.1.0-SNAPSHOT.jar --main-class io.github.b077as.jolt.examples.notes.NotesApp `
--java-options "--enable-native-access=ALL-UNNAMED" `
--add-modules "java.base,java.logging,jdk.unsupported" `
--jlink-options "--strip-debug --no-man-pages --no-header-files --compress zip-6" `
--dest dist
# result: dist\JoltNotes\JoltNotes.exe (about 32 MB; without --add-modules and --jlink-options about 120 MB)The resulting JoltNotes.exe passes the example's self-test on Windows. Add whatever modules your dependencies need to
--add-modules. If the app fails at start with a missing class, that is the first thing to check.
Good to know:
- The
app-imageabove is a folder you can zip and ship. Installers (--type msiorexe) need the WiX tools, and--type deborrpmneed their packaging tools. - The same
jpackagecommand should work on Linux, but it has not been run there. - Code signing is up to you.
- Users on Windows 10 need the WebView2 Runtime (a free download from Microsoft). Users on Linux need the packages listed under Requirements.
- Let
mainreturn instead of callingSystem.exit. On Windows a forced exit makes Microsoft's web view code printFailed to unregister class Chrome_WidgetWin_0. Error = 1411. It is harmless, but it never appears ifmainjust ends. - One app at a time per process.
runblocks until the app ends. On Linux the window system runs on a thread Jolt owns (the web libraries require one fixed thread), so do not assume the thread callingrunis the UI thread. Useapp.isUiThread()orapp.dispatch(Runnable)when you need to be sure. Tasks given todispatchafter the loop has ended are discarded. evalfails when the script throws or does not compile, on both platforms, with aJoltExceptionthat carries the script's error message. If the window closes first, it fails withWindowClosedException.- Listeners and futures run on the UI thread. Do not block it waiting for a future the UI thread has to complete.
- Close handling:
window.onClosed(...)runs once after a window is destroyed. By default the app ends when the last window closes (AppOptions.withQuitWhenLastWindowClosed(false)changes that). - Wayland limits are Wayland's, not Jolt's: an app cannot position its own window or keep it on top.
- Security: the page can call everything you register. Jolt only lets your own content and origins you list reach your
handlers by default; see Security. Treat handler parameters as untrusted input anyway: validate them, as
NotesStore.adddoes for an empty note.
loadHtml takes one string, so relative <link>, <script src> and ES modules have nothing to resolve against. To keep
HTML, CSS and JS in separate files, serve them with loadResources:
window.loadResources(AppResources.classpath("ui")); // src/main/resources/ui/index.html, css/, js/ ...
window.loadResources(AppResources.directory(Path.of("ui"))); // a folder on disk, handy while developingThe page can then use relative URLs, type="module", fetch, fonts and any front end (plain files, htmx, React, Vue, ...). The
files are served inside the web view, with no socket: WebView2 and WebKitGTK both let an application answer requests for an
address of its own, so only this window's web view can reach them. The page's origin is https://jolt.localhost on Windows
(the same as loadHtml pages) and jolt://app on Linux. It never changes, so localStorage, IndexedDB and cookies survive a
restart. Don't hard-code the origin in your page; use relative URLs.
Pass a RequestHandler and the page's own requests (fetch, XMLHttpRequest, <form method="post">, htmx's hx-get and
hx-post) are answered by Java before the files are. It does not care what the front end is: return HTML fragments for htmx,
JSON for a single-page app, or redirects for classic forms.
window.loadResources(AppResources.classpath("ui"), request -> switch (request.path()) {
case "/notes" -> AppResponse.html(renderNotes()); // GET /notes
case "/api/notes" -> AppResponse.text(notesAsJson()) // GET /api/notes
.withHeader("Content-Type", "application/json");
case "/notes/add" -> { // POST, from hx-post or a form
notes.add(request.formValue("text"));
yield AppResponse.html(renderNotes()).withHeader("HX-Trigger", "noteAdded");
}
default -> null; // null: let the files answer
});AppRequest has the method, path, query, headers and body, with formValue, queryParams and isHtmx() helpers.
AppResponse has html, text, status, redirect, withHeader and withStatus. Handlers run on worker threads (use
Application.dispatch to touch windows). Escape anything user-supplied before putting it in HTML, and keep GET handlers free of
side effects. Bodies over 8 MB (configurable, see Uploads) get 413; an exception in a handler gives a
bare 500 and the details go to the log, not the page.
Redirects work everywhere: a form post answered with AppResponse.redirect(...) (Post/Redirect/Get) ends on the new address.
WebKitGTK does not follow redirects from an in-process scheme itself, so on Linux Jolt does it: a page navigation gets a tiny page
that moves to the new address, and fetch/XMLHttpRequest get the target's answer directly (after a 301, 302 or 303 as a GET).
Only same-origin redirects are followed that way.
A lambda with a switch is fine for a few routes. For more, use Router, which is a RequestHandler:
Router router = Router.create()
.get("/", request -> AppResponse.html(pages.home()))
.get("/notes/{id}", request -> AppResponse.html(pages.note(request.param("id"))))
.post("/notes", request -> {
notes.add(request.formValue("text"));
return AppResponse.redirect("/notes");
})
.group("/admin", admin -> admin
.before(request -> loggedIn(request) ? null : AppResponse.redirect("/login")) // null: carry on
.get("/users", request -> AppResponse.html(pages.users())))
.onError((request, error) -> AppResponse.html(pages.error(error)).withStatus(500));
window.loadResources(AppResources.classpath("ui"), router);- Patterns:
/notes/{id}captures one segment (read it withrequest.param("id"), already URL-decoded);/files/{*path}as the last segment captures the rest. Trailing slashes make no difference; plain segments are case-sensitive. - Which route wins: a plain segment beats
{id}, which beats{*rest}, decided from the left; otherwise the route registered first. Registering the same method and shape twice (even with different parameter names) throws. - Fall-through: a request no route matches goes to your files, so static assets need no routes.
HEADuses theGETroute. APOST,PUT,PATCHorDELETEto a path that only has other methods gets 405 with anAllowheader; aGETthere falls through, so aPOST-only route never hides a file. - Filters (
before) run in order, outer groups first, only for requests that matched a route. Return a response to stop (a login redirect, a 403) ornullto continue. - Errors:
onErrorturns an exception from a filter or route into a response. Without it, or if it returnsnull, Jolt answers 500 and logs the cause.
A form with a file input, or fetch with a FormData, sends multipart/form-data. AppRequest.parts() reads it:
.post("/documents", request -> {
String title = request.formValue("title"); // the text fields work as usual
AppRequest.Part file = request.part("document"); // a file input
if (file == null || file.filename().isEmpty()) { // an empty name: no file was chosen
return AppResponse.html("<p>Choose a file</p>").withStatus(422);
}
Files.write(uploads.resolve(UUID.randomUUID() + ".bin"), file.content());
log.info("stored {} ({} bytes, {})", file.filename(), file.size(), file.contentType());
return AppResponse.redirect("/documents");
})<form method="post" action="/documents" enctype="multipart/form-data">
<input name="title"> <input type="file" name="document"> <button>Upload</button>
</form>It works with plain forms, fetch, and htmx (hx-post with hx-encoding="multipart/form-data"), on both platforms and both
transports. A Part has name(), filename() (null for an ordinary field), contentType(), headers(), content(),
text() and size(). parts() throws IllegalArgumentException for a body that is not well formed, which Router.onError can
turn into a 400.
- Treat the file as hostile.
filename()is cut down to its last path segment with control characters removed (so../../etc/passwdbecomespasswd), but it is still what the user's machine said; store files under a name you choose, as above.contentType()is what the browser guessed from the extension, not a check of the contents. - Size: bodies are held in memory, 8 MB by default; a bigger one is answered with 413 and never reaches your handler. Raise it for
uploads with
window.loadResources(resources, handler, ResourceOptions.defaults().withMaxRequestBytes(64 * 1024 * 1024))(up to 1 GB, held in memory, so size it to what you need). Streaming a large file to disk is not supported. - Linux: WebKitGTK aborts the whole process when a request to an in-process scheme carries a body holding a
Blob(anyfetchwith aBlob, anyFormDatawith a file in it, a multipart form with a file input), so Jolt's Linux backend turns those bodies into plain bytes inside the page before they are sent. You don't have to do anything:fetch,XMLHttpRequest(so htmx too),Requestobjects,navigator.sendBeaconand<form>submissions (requestSubmit()andform.submit()as well) all work with files, and what your handler receives is identical on Windows and Linux. Two corners are outside its reach: a synchronousXMLHttpRequestwith aBlobthrows aNotSupportedError(rather than crashing), and code in a Web Worker has to send bytes (ArrayBuffer), not aBlob. A form holding a file is sent as afetchand its answer replaces the page, which looks like a normal navigation (a redirect included) except that the address does not change to the form'saction. - Over the loopback fallback, Jolt adds its secret to multipart forms as a hidden field and removes it before your handler sees the
parts.
navigator.sendBeaconcannot carry the secret there and is refused.
The in-process transport needs WebView2 on Windows (always the case) and WebKitGTK 2.40 or newer on Linux (with libsoup 3, which every WebKitGTK 4.1 install has). A system that lacks them falls back automatically to a server on a loopback port; you can also ask for that, for example to open the same address in a normal browser to debug:
window.loadResources(resources, handler, 48123); // http://127.0.0.1:48123, fixed port so storage survives restartsTo combine a fixed port with a bigger upload limit, use
window.loadResourcesOverLoopback(resources, handler, ResourceOptions.defaults().withMaxRequestBytes(...), 48123).
Any program on the machine, and any website the user has open in a browser, can send requests to a loopback port, so Jolt guards it:
- CSRF:
POST/PUT/PATCH/DELETEare refused unless they carry a random per-run secret inX-Jolt-Token. Jolt adds it to htmx requests and tofetchcalls from your page, and only for URLs on its own server, so it never reaches another origin. Plain<form method="post">forms work too: Jolt adds the secret as a hidden_jolt_tokenfield when the form is submitted (for forms posting to its own server, urlencoded or multipart) and removes it before your handler sees the body. It is also available aswindow.joltCsrfTokenforXMLHttpRequest.form.submit()is covered too. Other body types such astext/plain, andnavigator.sendBeacon, cannot carry the secret and are refused. The handler never sees the secret. Originmust match if present,Sec-Fetch-Sitemust besame-originornone(this also rejects a page served from another port on the same machine), andHostmust name the server (DNS rebinding).- No CORS headers are sent and
OPTIONSis refused, so another origin cannot read a response.
None of this is needed in-process, where nothing but the window's own web view can send a request; a state-changing request there
is still refused if its Origin is not the app's.
The page can call everything you register with onMessage, so Jolt decides which pages may reach it. Each window has a
SecurityPolicy. The default, SecurityPolicy.standard(), trusts only your own content (what you pass to loadHtml or serve
with loadResources) plus origins you list, and blocks the rest:
| Protection | What it does when on | Switch |
|---|---|---|
| Navigation lock | Links, redirects, frames and loadUrl to an untrusted origin are cancelled |
withLockNavigation |
| Message check | Messages from an untrusted page never reach onMessage |
withRestrictMessages |
| New windows | window.open and target=_blank are refused (Windows; WebKitGTK never opens one itself) |
withBlockNewWindows |
| Permissions | Camera, microphone, location and similar requests are denied | withDenyPermissions |
| Bridge in frames | window.jolt exists only in the top-level page, not in iframes (set at window creation) |
withBridgeInSubframes |
An origin is scheme, host and port, such as http://localhost:5173. Change the policy when you create the window, or later
while it is open:
// Trust your dev server (Vite) or your own site, in addition to your bundled page
WindowOptions options = WindowOptions.defaults()
.withSecurity(SecurityPolicy.standard().withTrustedOrigins("http://localhost:5173"));
// Or change it at any time; it applies to everything that happens from then on
window.setSecurity(window.security().withTrustedOrigins("https://app.example.com"));
window.setSecurity(window.security().withLockNavigation(false)); // let the window go anywhere
// Everything off: any page can navigate, open windows, ask for permissions and call your handlers
WindowOptions open = WindowOptions.defaults().withSecurity(SecurityPolicy.permissive());When something is stopped, Jolt logs a warning and calls your onBlocked listener (on the UI thread, after the blocked
action), for example to open a blocked link in the user's own browser:
window.onBlocked(event -> {
if (event.kind() == BlockedEvent.Kind.NAVIGATION) {
openInSystemBrowser(event.url());
}
});Things to know:
loadUrlto another site is blocked by default. Add the origin withwithTrustedOrigins, as the notes example does for its dev server.- Trusting an origin gives that site your handlers. Whatever you register with
onMessagecan be called by every page from a trusted origin, so list only sites you control, and keep treating handler parameters as untrusted input. - Windows and Linux differ in one respect. WebView2 reports which page sent a message, so the check is exact. WebKitGTK does not say which frame sent it, so on Linux the check uses the top-level page, and the navigation lock is what keeps untrusted frames out of a trusted page. If you turn the navigation lock off on Linux, also leave the bridge out of frames (the default).
- A blocked navigation is cancelled before the page loads, but the request may still reach that server. Nothing from it is shown or run.
- WebView2 ignores a navigation to the exact address it has just cancelled; if you react to a block by trusting the origin, load a different page of the site, or load it before the first attempt.
- In the page, set text with
textContent, neverinnerHTML, when it comes from users or files.
A desktop app that logs in to something (a mail server, an API) has a password or token to keep. Do not write it to a file
of your own: put it in the operating system's keystore. jolt-credentials does that through the same FFM approach as the
rest of Jolt, with no native code to build. It does not depend on jolt-core, so it also works in a program with no window.
Available since 0.2.1; versions before that do not have it.
<dependency>
<groupId>io.github.b077as</groupId>
<artifactId>jolt-credentials</artifactId>
<version>${jolt.version}</version>
</dependency>Gradle: implementation("io.github.b077as:jolt-credentials:0.2.4").
CredentialStore store = CredentialStore.open("com.example.mail"); // your app's own namespace
store.put("alice@example.com", password); // replaces an earlier one
Optional<String> saved = store.get("alice@example.com");
store.delete("alice@example.com"); // true if there was one| Where the secret goes | Needs | |
|---|---|---|
| Windows | Credential Manager (CredWriteW, CredReadW, CredDeleteW) |
nothing. A secret is at most 2560 bytes in UTF-8. |
| Linux | the Secret Service through libsecret (GNOME Keyring, KWallet, KeePassXC) |
libsecret-1.so.0 (Debian and Ubuntu: libsecret-1-0) and a running, unlocked Secret Service |
- Check for it when you start.
CredentialStore.openthrowsCredentialStoreUnavailableExceptionon a machine with no keystore (macOS, nolibsecret, no Secret Service running, as on a minimal window manager or in a container). The message says why. Catch it and fall back to something else, such as asking the user each time. - Secrets are text (UTF-8, not empty, no NUL characters). An account is any non-blank name up to 255 characters.
- Call it off the UI thread. Every call blocks, and on Linux it may wait for the user to unlock the keyring.
- What it protects against: other users of the machine and someone reading the disk. It does not protect against a
program running as the same user, which can ask the keystore for the same secrets. A Java
Stringcannot be wiped from memory, so do not hold one longer than you need to.
This is the notes example, which you can copy as a starting point:
notes-app/
pom.xml depends on jolt-core, jolt-win32, jolt-linux (+ your libraries)
src/main/java/.../NotesApp.java main(): create the app and window, register handlers, load the page
src/main/java/.../Rpc.java the request/response layer (copy it)
src/main/java/.../NotesStore.java the app's own logic and data
src/main/java/.../SettingsStore.java remembered settings (the theme), read before the window is created
src/main/java/.../ThemeRequest.java validates what the page sends before it reaches the operating system
src/main/resources/ui/index.html the page (plain HTML and CSS version)
src/main/resources/icon.ico the window icon, drawn at nine sizes so Windows never has to scale it
frontend/ the same interface as a React + TypeScript + Tailwind project
src/test/java/.../*Test.java your logic is plain Java, so test it with plain JUnit
Keep your logic in ordinary classes with no window code in them, as NotesStore, SettingsStore and ThemeRequest do.
They then test without a window, and only NotesApp and Rpc touch Jolt.
| Symptom | Likely cause |
|---|---|
No usable Jolt platform backend: ... WebView2 Runtime is not installed |
Install the WebView2 Runtime (Windows). |
GTK 3 / WebKitGTK 4.1 could not be loaded |
Install libgtk-3-0 and libwebkit2gtk-4.1-0. The error message names the packages for Debian/Ubuntu, Fedora and Arch. |
GTK could not open a display |
No DISPLAY or WAYLAND_DISPLAY: you are on a server or in a container without a display. |
| Warning about native access when starting | Add --enable-native-access=ALL-UNNAMED (or Enable-Native-Access: ALL-UNNAMED in your jar manifest). |
Could not find or load main class |
The class path is wrong. The examples use a path relative to the repository root, so run them from there. |
Could not find or load main class .url=... (PowerShell) |
PowerShell split -Dui.url=... at the dot. Quote it: "-Dui.url=http://127.0.0.1:5173". |
| The window opens but stays blank | Open developer tools (see Run it) and look at the console. With loadUrl, check the dev server is running. |
call(...) never resolves |
Your handler never replied, or the page was reloaded (pending calls belong to the old page). |
| The window freezes | Something slow ran inside a message listener. Move it to a worker thread. |
| The old icon still shows in the taskbar | Windows caches it. Unpin and re-pin the app, or restart Explorer. |
$env:JAVA_HOME = 'C:\path\to\jdk-25' # any JDK 25
mvn install # builds everything and runs all tests (they open real windows)Run the smallest example, a window with a scripted round trip (--smoke runs an automatic self-check):
$r = (Get-Location).Path
java --enable-native-access=ALL-UNNAMED `
-cp "$r\examples\hello-window\target\classes;$r\jolt-core\target\classes;$r\jolt-win32\target\classes;$r\jolt-linux\target\classes" `
io.github.b077as.jolt.examples.hello.HelloWindowRun the full notes example, in either interface (from the repository root):
mvn -pl examples/notes-app -am install -DskipTests
# plain HTML interface
java --enable-native-access=ALL-UNNAMED -cp "examples/notes-app/target/package/*" io.github.b077as.jolt.examples.notes.NotesApp
# React interface (build the frontend first: cd examples\notes-app\frontend; npm install; npm run build)
java --enable-native-access=ALL-UNNAMED "-Dui.file=examples/notes-app/frontend/dist/index.html" -cp "examples/notes-app/target/package/*" io.github.b077as.jolt.examples.notes.NotesApp| Module | What |
|---|---|
jolt-core |
Public API (Application, WebWindow), platform SPI, shared FFM helpers, page-side bridge script |
jolt-win32 |
Windows backend: Win32 window and WebView2, driven through COM vtables from Java |
jolt-linux |
Linux backend: GTK 3 window and WebKitGTK 4.1, driven through FFM |
examples/hello-window |
Smallest app; --smoke runs a scripted round trip |
examples/notes-app |
A complete small app (Java data, JSON requests, HTML page); the worked example above |
examples/notes-app/frontend |
The same app's interface as a React + TypeScript + Tailwind project, with hot reload and a single-file build |
Application.run turns the calling thread into the UI thread and runs the platform message loop. Anything from another
thread goes through dispatch, which queues the task and wakes the loop. On Windows, WebView2's COM interfaces are called
straight through their vtables with FFM, and the callback interfaces WebView2 wants are implemented in Java as COM objects
whose methods are FFM upcall stubs. The vtable slot numbers are generated from Microsoft's header rather than typed by
hand, because a wrong slot or signature crashes the JVM instead of throwing.
loadResources rests on one transport-independent class, which serves the files, calls your handler and turns failures into
HTTP errors. On Windows the web view hands it every request to https://jolt.localhost (WebView2's request interception, answered
asynchronously so a slow handler never blocks the UI thread). On Linux it is a URI scheme registered with WebKitGTK. Both read the
request, run the handler on a virtual thread, and write the response back on the UI thread. Where that is not possible, the same
class sits behind a small HTTP server on the loopback interface, with the guards described above. Linux has two extra pieces, both
there because of WebKitGTK: Jolt follows same-origin redirects itself, and a small script in the page turns request bodies that hold
a Blob into bytes, since WebKitGTK aborts the process on them.