Open a native window, load a web page, and talk to it from Java.
Your code and your page run in one process, with no HTTP server in between.
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>");
});That is a complete app: a native window, a real web view, and messages in both directions. The page draws the interface. Java does everything a page may not: files, databases, native libraries, long-running work.
- Tiny. No bundled browser. Jolt uses the web view the OS already has (WebView2 on Windows, WebKitGTK on Linux). A packaged app with its own Java runtime is about 32 MB.
- No native build step. Built on Java 25's Foreign Function & Memory API. The only binary shipped is Microsoft's
WebView2Loader.dll. - Any frontend. Plain HTML, React, Vue, Svelte, htmx: anything a browser can show.
- One jar, two platforms. Windows 10/11 and Linux (X11 and Wayland).
- Secure by default. Only your own pages can reach your Java code. See Security.
| Jolt | Electron | Tauri | Wails | JavaFX WebView | |
|---|---|---|---|---|---|
| Backend language | Java | JavaScript | Rust | Go | Java |
| Ships its own browser | No | Yes | No | No | Yes, a WebKit build inside JavaFX |
| Native toolchain to build | None | None | Rust | Go | None |
You need JDK 25 and Maven 3.9+. On Linux also sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0.
Add Jolt to your project below opens your first window in a few lines.
For a complete app, see Parsel, a desktop mail client built on Jolt: Java for IMAP and SMTP, a Vue page for the interface, one process, packaged for Windows and Linux. It is the main example of how a real Jolt app is put together, and there is a case study on the Jolt site.
Status: early. Both backends pass the same integration suite (Windows; Linux on X11 and Wayland in containers). macOS is not supported. If something is rough, please open an issue.
Add the API and both backends; each one only activates on its own OS.
<properties>
<maven.compiler.release>25</maven.compiler.release>
<jolt.version>0.4.1</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>Gradle:
implementation("io.github.b077as:jolt-core:0.4.1")
implementation("io.github.b077as:jolt-win32:0.4.1")
implementation("io.github.b077as:jolt-linux:0.4.1")Run Java with --enable-native-access=ALL-UNNAMED, or put Enable-Native-Access: ALL-UNNAMED in your jar manifest
(maven-jar-plugin, <manifestEntries>), otherwise Java prints a warning on every start.
Requirements: JDK 25; on Windows the WebView2 Runtime
(preinstalled on Windows 11); on Linux GTK 3 and WebKitGTK 4.1 (libgtk-3-0 libwebkit2gtk-4.1-0).
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("""
<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();
}AppOptions.of(id)names your app. It picks a per-user data folder (%LOCALAPPDATA%\<id>or~/.local/share/<id>).app.run(onStart)blocks until the app ends (last window closed, orquit()). Create windows insideonStart.window.joltis injected into every page:jolt.postMessage(text)sends to Java,jolt.onMessage(cb)receives.window.eval(script)runs a script and returns aCompletableFuture<String>with the JSON result.window.addInitScript(script)runs before the page's own scripts.- Every
ApplicationandWebWindowmethod may be called from any thread. Message listeners run on the UI thread, so never block there (for example by calling.get()on anevalresult) and hand slow work to another thread. - To end the app, let
mainreturn rather than callingSystem.exit. app.openExternal(URI)opens anhttp,httpsormailtoaddress in the user's browser or mail program (since 0.2.5).
Messages are plain strings. For real apps, add a tiny request/response layer on top: the page sends
{"id": 1, "method": "notes.add", "params": {...}} and Java answers {"id": 1, "result": ...} or {"id": 1, "error": "..."}.
A small Rpc helper of your own (about 40 lines on the Java side and as many on the page) is all it takes:
new Rpc(window)
.on("notes.list", params -> store.list())
.on("notes.add", params -> store.add(params.get("text").getAsString()));const note = await call('notes.add', { text: 'Buy milk' });Copy that pattern into your project. It works with any JSON library.
For big or binary data (video frames, images, files) open a named two-way channel. No base64, no JSON (since 0.2.3).
BinaryChannel frames = window.openChannel("frames"); // open it before the page loads
frames.onMessage(bytes -> handle(bytes));
frames.send(ByteBuffer.wrap(pixels)); // never blocks; returns false if refusedconst frames = jolt.channel("frames");
frames.onMessage(data => { /* ArrayBuffer */ });
frames.send(new Uint8Array([1, 2, 3]));- The page connects and reconnects by itself.
onOpen,onCloseandisOpen()tell you the state. ChannelOptions.newestOnly()keeps only the latest message for a slow page (live video);withMaxQueuedBytesbounds the queue.- Callbacks run on a thread of their own, not the UI thread.
- A channel is a WebSocket on
127.0.0.1, accepted only from origins yourSecurityPolicytrusts and with a per-run secret. - On Linux, use
loadResources(notloadHtml) for pages that open channels: WebKitGTK blocks the connection otherwise.
A page never sees a file's path. Use the OS dialogs through the window (since 0.2.4):
Dialogs dialogs = window.dialogs();
dialogs.saveFile(FileDialogOptions.defaults()
.withTitle("Export report").withFileName("report.pdf")
.withFilters(FileFilter.of("PDF documents", "pdf")))
.thenAccept(chosen -> chosen.ifPresent(path -> writeReport(path))); // empty if cancelled
dialogs.openFile(FileDialogOptions.defaults()); // also openFiles(...) and chooseFolder(...)Each call returns a CompletableFuture at once. Cancelling gives an empty result, not an error. withSize(w, h) asks for a
bigger dialog (a request: the system owns it).
To let the page save a file, call window.enableSaveAs() in Java and jolt.saveAs in the page:
const saved = await jolt.saveAs(blobOrBytesOrText, { name: "report.pdf", filters: [{ name: "PDF", extensions: ["pdf"] }] });
// { name, size } or null if cancelled; the page never learns the pathThe data is written to a temporary file and moved into place when complete. Only trusted origins may use it, and on Linux the page
must come from loadResources.
A page is never told where a file lives, so a dropped file would be a name and some bytes. Jolt gives the path to Java instead (since 0.4.1).
Drop files onto the window (from Explorer, Nautilus, the desktop):
window.onFileDrop(drop -> { // UI thread; register before the page loads
if ("avatar".equals(drop.zone())) setAvatar(drop.files().get(0));
else addAttachments(drop.files()); // absolute local paths, files or folders
});<div data-jolt-drop="avatar">Drop a picture here</div>
<style>
[data-jolt-drop-over] { outline: 2px dashed dodgerblue; } /* the area under the pointer */
html[data-jolt-drop-active] body { cursor: copy; } /* files are being dragged over the window */
</style>zone is the data-jolt-drop value of the element the files landed on (null outside every such element), x and y are
where the pointer was in page pixels. Once onFileDrop is registered the web view never navigates to a dropped file, and the page's
own drop handlers still run (so it can show a preview with FileReader). Every path must be absolute and local (a
\\server\share path is refused, as for the clipboard) and the files must be the ones the system reported: the page only adds the
names and the drop zone, and a message that does not match is ignored, so a page cannot name a file of its own.
Drag files out of the window into the file manager or another application:
window.setFileDragSource(token -> token.startsWith("attachment:")
? List.of(store.fileOf(token.substring("attachment:".length()))) // absolute paths of files that exist
: null); // null or empty: no drag
// optional: implement FileDragSource.finished(token, dropped) to delete a temporary file afterwards<li draggable="true" data-jolt-drag-file="attachment:42">report.pdf</li>The page only hands over the token you put in the attribute; you decide which files it stands for, so a page cannot drag out an arbitrary file. The target can only copy (a drop on a folder never moves your original). The source is asked on the UI thread the moment the drag starts, so write a file that does not exist yet before the user can start dragging it.
Windows uses the shell's own drag loop and data object (what Explorer offers for the same files); Linux offers a text/uri-list through
GTK. Needs the WebView2 runtime that supports postMessageWithAdditionalObjects (any current one) for dropping in.
Show a toast from Java (since 0.3.0): WinRT on Windows, the desktop's notification service on Linux.
app.notify(Notification.of("Export finished", "report.pdf is ready")
.withOnClick(() -> window.focus())
.withButton("Open", () -> open()) // up to 5 buttons
.withReply("Send", "Reply", text -> send(text))) // a text field: Windows only
.thenAccept(shown -> { /* false: the system did not show it */ });Callbacks run on the UI thread while the app runs. On Windows the first call registers your appId as the display name under
HKCU\Software\Classes\AppUserModelId (nothing is installed), and the future is false if the user turned notifications off. On Linux it
is false when there is no notification service. To let trusted pages do it, call window.enableNotifications(app) before the page
loads, then await jolt.notify(title, body, { onclick, buttons: [{ label, onclick }], reply: { label, placeholder, onreply } })
(at most 5 per 10 seconds per window).
Available since 0.3.0.
app.createWindow(...) can be called as often as you like (on the UI thread), and app.windows() lists the windows that are open.
A page can ask for another window, for example an email shown in its own window, but it never opens one itself: your Java code
decides.
main.enableOpenWindow(app, request -> { // runs on the UI thread, for each request from a page
if (!request.path().startsWith("/message/")) return null; // null refuses; the page's promise resolves false
WebWindow popout = request.createWindow(app); // title and size come from the page's suggestions
popout.loadResources(resources, router,
ResourceOptions.defaults().withStartPath(request.path())); // the new window opens on /message/42
return popout;
});const opened = await jolt.openWindow("/message/42", { key: "message-42", title: "Re: lunch", width: 900, height: 700 });
// true: a window opened, or the one with that key was brought to the front. false: the app refused.keystops duplicates: asking again for the same key focuses the window that is already open (your opener is not called).pathmust be an address inside the app (one leading/);//host,https://...and the like are rejected.- The window you return gets
jolt.openWindowandjolt.broadcasttoo, with the same opener; refuse inside the opener (look atrequest.opener()) if pop-outs must not open more windows. At most 10 requests per window per 10 seconds. - The request is yours to check:
path,key,titleand the sizes come from the page.request.options()has no owner; use.withOwner(request.opener())if the pop-out should stay in front of its window and close with it. Without an owner it is an independent window. - Talking between windows:
jolt.broadcast(text)goes to every other window that has calledenableOpenWindow, andconst stop = jolt.onBroadcast(text => ...)receives it (a window never gets its own broadcasts;textis limited to 16 K characters). To refresh the message list after a reply, broadcast a short event such as"message-42:read"and let each window fetch what it needs. - From Java:
window.isFocused()tells whether the window is the one the user is working in (since 0.3.1; for example, skip a notification while it is),window.focus()brings a window to the front (restoring or showing it),WindowOptions.withOwner(window)makes a window belong to another one.
Your page is normal HTML, CSS and JavaScript. The web view is Chromium (Windows) or WebKit (Linux), so write for both.
-
Development: point the window at your dev server and let it do hot reload:
window.setSecurity(window.security().withTrustedOrigins("http://localhost:5173")); window.loadUrl("http://localhost:5173");
Or serve a folder from disk with
loadResources(AppResources.directory(Path.of("ui"))); files are re-read on every request. -
Release: put the built page in
src/main/resources/uiand serve it from the jar:window.loadResources(AppResources.classpath("ui"));
Separate HTML, CSS and JS files work, relative URLs and ES modules too, and the origin is stable so
localStoragesurvives restarts. A single self-contained HTML file (Vite withvite-plugin-singlefile) can useloadHtmlinstead.
A small typed wrapper around jolt.postMessage (for example a call<T>(method, params) function with one function per Java
handler) keeps the rest of the frontend free of the bridge. Parsel is built this way, with a
Vue page on top of Vite. Other frameworks go through the same Vite setup.
loadResources shows a multi-file page and lets Java answer its requests (fetch, forms, htmx hx-get/hx-post, JSON APIs).
Files are served inside the web view, with no socket. The origin is https://jolt.localhost on Windows and jolt://app on
Linux; use relative URLs.
window.loadResources(AppResources.classpath("ui"), request -> switch (request.path()) {
case "/notes" -> AppResponse.html(renderNotes());
case "/notes/add" -> {
notes.add(request.formValue("text"));
yield AppResponse.html(renderNotes());
}
default -> null; // null: let the files answer
});For more than a few routes use Router, which is a RequestHandler:
Router router = Router.create()
.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"))
.get("/users", request -> AppResponse.html(pages.users())))
.onError((request, error) -> AppResponse.html(pages.error(error)).withStatus(500));
window.loadResources(AppResources.classpath("ui"), router);- Patterns:
{id}captures one segment,{*rest}the rest. A plain segment beats{id}, which beats{*rest}. - A request no route matches falls through to your files. A wrong method on a known path gets 405 with
Allow. - Handlers run on worker threads. An exception becomes a bare 500 and the details go to the log, never to the page.
- Escape anything user-supplied before putting it in HTML, and keep
GEThandlers free of side effects.
Uploads. multipart/form-data works with plain forms, fetch and htmx. Read it with request.parts() / request.part(name):
AppRequest.Part file = request.part("document");
if (file != null && !file.filename().isEmpty()) {
Files.write(uploads.resolve(UUID.randomUUID() + ".bin"), file.content()); // choose the stored name yourself
}Treat uploaded files as hostile: filename() and contentType() come from the user's machine. Bodies are held in memory, 8 MB
by default (413 above that); raise it with ResourceOptions.defaults().withMaxRequestBytes(...) (up to 1 GB). On Linux, Jolt works
around a WebKitGTK crash on Blob request bodies by turning them into bytes inside the page; a synchronous XMLHttpRequest with a
Blob throws NotSupportedError. That workaround does not reach Web Workers: on Linux a worker must send strings or
ArrayBuffers (await blob.arrayBuffer()), never a Blob or a FormData holding one, because WebKitGTK aborts the whole process.
Loopback fallback. The in-process transport needs WebKitGTK 2.40+ on Linux. Older systems fall back to a guarded server on
127.0.0.1 automatically (per-run CSRF token, Origin/Host/Sec-Fetch-Site checks, no CORS). You can also ask for it, for
example to open the app in a normal browser: window.loadResources(resources, handler, 48123).
The OS draws the icon and title bar, so you set them from Java, at creation or later:
WindowOptions options = WindowOptions.defaults()
.withSize(1200, 780)
.withMinSize(600, 400) // smallest size the user can drag to
.withIcon(iconBytes) // multi-size .ico (best) or PNG
.withTitleBarScheme(ColorScheme.DARK)
.withBackgroundColor(0x0F1117) // behind the page until it paints: no white flash
.withMaximized(false)
.withCentered(true) // the default
.withVisible(false); // call window.show() when the page is ready
window.setTitleBarColors(0xF4F5FA, 0x1A1D29); // exact colours, Windows 11 only
window.maximize(); window.restore(); window.isMaximized();- Use an
.icowith 16, 24, 32, 48 and 256 px images: Windows picks the matching size instead of scaling one bitmap. On Linux the largest image is used. - Start hidden and call
show()when the page sends "ready" so the window appears with its content already drawn. - On Wayland the compositor decides placement, so
withCenteredis ignored and the icon comes from the app's.desktopfile. Jolt names the process after your app id, which is what lets the compositor find it (since 0.2.5). - On Linux, GTK sometimes prints harmless warnings to the console (for example from its file chooser).
AppOptions.of(id).withQuietNativeLogs(true)hides that known list and leaves other messages alone (since 0.2.5). - A fully custom title bar: see Frameless windows below.
- Developer tools:
WindowOptions.defaults().withDevTools(true), then right-click, Inspect, orwindow.openDevTools().
Available since 0.3.0.
WindowOptions.defaults().withFrameless(true) removes the operating system's title bar and border, so the page draws its own:
<header data-jolt-drag> <!-- drag here to move the window; double-click maximizes or restores -->
<span>Parsel</span>
<button onclick="jolt.frame.minimize()">–</button>
<button onclick="jolt.frame.toggleMaximize()">☐</button>
<button onclick="jolt.frame.close()">✕</button>
</header>data-jolt-dragmarks the draggable area. Buttons, links and form fields inside it keep working; adddata-jolt-no-dragto exclude anything else. (CSSapp-region: dragis not used: WebKitGTK does not support it.)- The window can be resized from thin invisible handles along its edges and corners, which Jolt adds to the page.
jolt.frame.close()closes the window as the close button would, sosetCloseBehavior(HIDE)applies to it too.- On Windows the window keeps its sizing frame (the page is given the whole window), which is what lets Windows draw the shadow and snap it (Win+arrows); the tests check the layout, not how the shadow and snapping look. Linux draws no shadow and snaps only as your window manager does; on Wayland, moving and resizing needs a compositor that accepts the request.
- Frame commands only work on windows created
frameless, and only for pages theSecurityPolicytrusts like any other message.
Instead of writing the buttons yourself, mark the bar with data-jolt-titlebar:
<style>
header { display: flex; align-items: center; height: 32px; padding-left: 12px;
background: #161616; color: #ddd;
--jolt-caption-hover-bg: #2a2a2a; /* hover colour of minimize and maximize */
--jolt-close-hover-bg: #e5484d; /* the close button; red by default */
--jolt-maximize-fg: #7aa2f7; } /* one button on its own */
</style>
<header data-jolt-titlebar><span>Parsel</span></header> <!-- a dialog: data-jolt-titlebar="close" -->The bar moves the window; minimize, maximize/restore and close are added at its right end, so make the bar display: flex.
Colours are CSS custom properties: --jolt-caption-{fg,bg,hover-fg,hover-bg,active-fg,active-bg} for all buttons, or
--jolt-{minimize,maximize,close}-{...} for one (the specific one wins). Also --jolt-caption-width (46px), --jolt-caption-icon
(10px) and --jolt-caption-inactive-opacity (.55). The buttons are in a shadow root, so page CSS cannot break them.
The window tells the page what state it is in, which you can use with or without the kit:
<html data-jolt-maximized>while maximized,<html data-jolt-inactive>while the window is not the one in front,<html data-jolt-platform="windows|linux">(for example to hide a rounded corner while maximized:html[data-jolt-maximized] header { border-radius: 0 }).- Full screen from Java:
window.setFullscreen(true)/window.isFullscreen(). A frameless page can usejolt.frame.setFullscreen(true),jolt.frame.toggleFullscreen()andjolt.frame.isFullscreen();<html data-jolt-fullscreen>is set while it is on. jolt.frame.isMaximized(),jolt.frame.isFocused(), andconst stop = jolt.frame.onChange(({maximized, focused}) => ...).
On Windows 11, resting the pointer on the maximize button shows the snap layouts. For that Jolt reports the button's position to the window
(the kit's button, or any element you mark data-jolt-maximize) and puts a nearly invisible child window over it. While it is there the
page gets no pointer events for that button, so Jolt sets data-jolt-hover and data-jolt-pressed on your element instead; style
[data-jolt-maximize][data-jolt-hover] as you would :hover. Clicks still maximize through the window itself. On Windows 10 and Linux
nothing is added and the button works as an ordinary button. Also on Windows, right-clicking a drag region or the title bar opens the system
window menu (jolt.frame.menu() does it from code). Linux has no snap layouts, no window menu on right click, and no shadow or rounded
corners for frameless windows; whether those exist depends on the window manager. The snap flyout and the look of the kit were not checked by eye.
Available since 0.3.0.
A mail client or chat app usually lives in the tray, hides instead of quitting when its window is closed, and can start with the computer. These are separate pieces, so you choose which to use, and each can be changed while the app runs.
Application app = ...;
WebWindow main = ...;
if (app.isTraySupported()) { // Windows: always. Linux: needs a tray host (see below)
Tray tray = app.createTray(iconBytes, "Parsel"); // call on the UI thread
tray.onClick(main::focus); // primary click: bring the window back
tray.setMenu(TrayMenu.of(
TrayMenu.action("Open Parsel", main::focus),
TrayMenu.separator(),
TrayMenu.check("Close to tray", closeToTray, on -> setCloseToTray(on)),
TrayMenu.check("Start with the computer", autostart, on -> setAutostart(on)),
TrayMenu.separator(),
TrayMenu.action("Quit", app::quit)));
}main.setCloseBehavior(closeToTray ? CloseBehavior.HIDE : CloseBehavior.CLOSE); // takes effect on the next click of the close button
Autostart autostart = app.autostart();
autostart.enable(List.of(exePath, "--minimized")); // start with the computer, minimized...
autostart.enable(List.of(exePath)); // ...or not: calling it again replaces the entry
autostart.disable(); autostart.isEnabled();- Menus are immutable. To tick a box or change a label, build the menu again and call
tray.setMenu(...);tray.setIconandtray.setTooltipdo the same for the icon and its text (for example "3 unread"). Menu callbacks run on the UI thread. - Start minimized is an argument you choose: put
--minimizedin the autostart command, and inmaincreate the window withwithVisible(false)(and a tray) whenargscontains it. Only hide at startup ifapp.isTraySupported(), or the user cannot reach the app. - Close to tray needs a tray.
CloseBehavior.HIDEonly hides the window;focus()orshow()brings it back,close()andapp.quit()still really close it. CheckisTraySupported()before choosingHIDE. - The command: give the launcher of your installed or packaged app (on Windows a quoted path in the Run key, on Linux the
Exec=line of~/.config/autostart/<appId>.desktop).Autostart.currentCommand(extraArgs)describes the running process, which is fine forjava -jarwhile developing. - Windows: the tray menu is a native menu; Jolt asks Windows to draw it in dark mode when the system is dark, using an undocumented call that is skipped if it is missing. It supports checks, disabled entries, separators and submenus, but not icons. If Explorer restarts, the icon is put back.
- Linux: the tray is a StatusNotifierItem with a
dbusmenumenu, which KDE Plasma, XFCE, Cinnamon, MATE, Budgie and others show. Stock GNOME does not show tray icons unless the user installed the AppIndicator extension, soisTraySupported()isfalsethere. If the tray host restarts while the app runs, the icon is not registered again. - One copy only: a tray app that starts at sign-in should not start twice.
public static void main(String[] args) {
AppOptions options = AppOptions.of("com.example.parsel");
SingleInstance instance = SingleInstance.acquire(options, List.of(args), otherArgs -> app.dispatch(() -> main.focus()));
if (!instance.isPrimary()) {
return; // the running copy was told (and receives this copy's arguments); nothing more to do
}
...
}SingleInstance takes a lock file in the data directory (released by the system if the app dies) and talks to the running copy over a
Unix domain socket that only the same user can reach. The callback runs on a thread of its own: use app.dispatch.
When the pointer rests on the taskbar icon, Windows shows the window's preview, and under it a strip of buttons can offer actions that
need no trip to the window (mute, pause, leave a call). Available since 0.4.1; Windows only: elsewhere
supportsTaskbarButtons() is false and the call does nothing.
window.setTaskbarButtons(List.of(
TaskbarButton.of("Mute", muteIcon, () -> toggleMute()), // icon: PNG or ICO bytes, drawn about 16 px
TaskbarButton.of("Leave call", hangupIcon, () -> leave()).withEnabled(inCall)));Up to 7 buttons; callbacks run on the UI thread. Windows lets a program add the buttons only once, so the number is fixed by the first
call that has any: later calls update icons, tooltips and enabled state, and may give fewer (the rest are hidden), but giving more throws
IllegalArgumentException. Call it again whenever the state changes (swap the mute icon, disable a button). The buttons appear once the
window has been shown, and are added again if Explorer restarts.
A bar along the window's taskbar icon shows a download, export or upload without bringing the window up (since 0.4.1). On Windows this is the taskbar
icon's progress. On Linux it is best effort: Jolt sends the Unity launcher signal (com.canonical.Unity.LauncherEntry), which KDE Plasma,
Ubuntu's dock and Dash to Dock draw; plain GNOME Shell and most others ignore it. The desktop finds your icon through an installed
<appId>.desktop file, so an app run from a bare jar shows nothing; Linux has one bar per application and no red/yellow states.
window.setTaskbarProgress(TaskbarProgress.normal(done / (double) total)); // green bar
window.setTaskbarProgress(TaskbarProgress.indeterminate()); // length unknown
window.setTaskbarProgress(TaskbarProgress.error(0.6)); // red: it failed
window.setTaskbarProgress(TaskbarProgress.none()); // done: clear itCall it from any thread. It is shown as soon as the window has a taskbar button (so you can set it before the window is shown) and again if Explorer restarts.
Right-clicking the application's icon in the taskbar (Windows) or launcher (Linux) can offer actions that need no window first: tasks like "New incognito window", and, on Windows, your own categories like "Recent projects". Available since 0.4.1.
app.setJumpList(JumpList.builder()
.task("New incognito window", "--incognito") // starts the program with these arguments
.task(JumpItem.of("Settings", "--settings").withDescription("Open the settings"))
.category("Recent projects", List.of( // Windows only
JumpItem.of("Parsel", "--open", "42"),
JumpItem.of("Notes", "--open", "7")))
.build());Choosing an entry starts the program again with those arguments, so receive them in the copy that is already running with
SingleInstance.acquire(options, args, onOtherLaunch) (the new copy hands its arguments over and exits). Call setJumpList again whenever
the entries change; JumpList.empty() removes them. supportsJumpList() says whether the platform has them.
- Windows: from the first call the windows carry
AppOptions.appIdas their AppUserModelID, so the entries sit on this application's icon and not on Java's. The id must be at most 128 characters without spaces. Windows hides categories when the user has turned off "Show recently opened items" in Settings; tasks always show. The command to start is the process's own (for a plain Java launch rebuilt as-jar app.jar; JVM options are not carried over, so putEnable-Native-Access: ALL-UNNAMEDin the jar's manifest), or setbuilder().command(List.of(...))yourself. - Linux: best effort. Tasks are written as desktop actions into
~/.local/share/applications/<appId>.desktop, which GNOME and KDE show on the launcher icon when the running window is matched to that file. Categories and icons are not used, and a.desktopfile of your own at that place is never changed.
The page can call everything you register with onMessage, so each window has a SecurityPolicy. The default,
SecurityPolicy.standard(), trusts your own content plus origins you list, and blocks the rest:
| Protection | 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 |
withBlockNewWindows |
| Permissions | Camera, microphone, location and similar are denied | withDenyPermissions; allow microphone/camera for your own pages with withGrantedPermissions(Permission.MICROPHONE) |
| Bridge in frames | window.jolt exists only in the top-level page |
withBridgeInSubframes |
WindowOptions.defaults().withSecurity(SecurityPolicy.standard().withTrustedOrigins("http://localhost:5173"));
window.setSecurity(window.security().withLockNavigation(false)); // change it any time
window.onBlocked(event -> openInSystemBrowser(event.url())); // called after something was blockedloadUrlto another site is blocked unless you trust its origin.- Trusting an origin gives that site your handlers. List only sites you control, and still validate every parameter.
- WebView2 reports which frame sent a message; WebKitGTK does not, so on Linux the check uses the top-level page. If you turn the navigation lock off on Linux, keep the bridge out of frames (the default).
- In the page, set user-supplied text with
textContent, neverinnerHTML.
An optional module (since 0.2.1) that keeps secrets in the OS keystore, with no window needed:
CredentialStore store = CredentialStore.open("com.example.mail");
store.put("alice@example.com", password);
Optional<String> saved = store.get("alice@example.com");
store.delete("alice@example.com");Add io.github.b077as:jolt-credentials:${jolt.version}. Windows uses the Credential Manager (secrets up to 2560 bytes in UTF-8).
Linux uses the Secret Service through libsecret (libsecret-1-0) and needs a running, unlocked keyring. open throws
CredentialStoreUnavailableException when there is none, so catch it and fall back. Call it off the UI thread: it can block while
the user unlocks the keyring.
jpackage turns your jars into a double-clickable app that bundles its own Java runtime. Collect your app jar and its
dependencies in one folder (here target\package), then point it at that folder:
jpackage --type app-image --name MyApp `
--input target\package `
--main-jar my-app.jar --main-class com.example.MyApp `
--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 distThat gives about 32 MB (about 120 MB without --add-modules and --jlink-options). Add the modules your own dependencies need.
This was run on Windows; the same command should work on Linux but has not been tried. Code signing is up to you.
| Symptom | Likely cause |
|---|---|
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. |
GTK could not open a display |
No DISPLAY or WAYLAND_DISPLAY: a server or container without a display. |
| Warning about native access at start | Add --enable-native-access=ALL-UNNAMED. |
Could not find or load main class .url=... |
PowerShell split -Dui.url=... at the dot. Quote it. |
| Window stays blank | Open developer tools and read the console. With loadUrl, check the dev server is running. |
call(...) never resolves |
Your handler never replied, or the page was reloaded. |
| The window freezes | Something slow ran in a message listener. Move it to a worker thread. |
| Old icon in the taskbar | Windows caches it. Unpin and re-pin, or restart Explorer. |
mvn install # builds everything and runs all tests (they open real windows)| Module | What |
|---|---|
jolt-core |
Public API, platform SPI, shared helpers, the page-side bridge script |
jolt-win32 |
Windows backend: Win32 window and WebView2, driven through COM vtables from Java |
jolt-linux |
Linux backend: GTK 3 and WebKitGTK 4.1 over FFM |
jolt-credentials |
OS keystore for passwords and tokens |
How it works, briefly: Application.run turns the calling thread into the UI thread and runs the platform message loop; other
threads go through dispatch. On Windows, WebView2's COM interfaces are called through their vtables with FFM (slot numbers are
generated from Microsoft's header, because a wrong slot crashes the JVM), and callback interfaces are Java objects backed by
upcall stubs. loadResources sits on one transport-independent class that serves files, calls your handler and maps failures to
HTTP errors, behind WebView2's request interception, a WebKitGTK URI scheme, or the loopback server.
See the changelog for what each version added.