A cross-platform native WebView component for embedding in Java Swing applications. Java port of the tiny, light-weight WebView by Serge Zaitsev.
Add the dependency to your pom.xml:
<dependency>
<groupId>ca.weblite</groupId>
<artifactId>webview</artifactId>
<version>1.0.10</version>
</dependency>The jar bundles the native libraries for macOS, Linux, and Windows — no additional native install step is required beyond the platform's system web engine:
- Windows requires the system-wide Microsoft Edge WebView2 Runtime,
which ships with current Windows 11 / Edge. On older Windows, install
the Evergreen Runtime from
https://developer.microsoft.com/microsoft-edge/webview2/. WebView2 user
data is stored in a per-application directory under
%LOCALAPPDATA%\SwingWebView; setWEBVIEW2_USER_DATA_FOLDERbefore launch to override that location. An existing, writable<executable>.WebView2folder beside the host executable (WebView2's own default, used by earlier releases) keeps being used so upgrading does not discard cookies or site storage. - Linux requires a system WebKitGTK — either 4.1 (Ubuntu 22.04+)
or 4.0 (Ubuntu 20.04). The bundled
libwebview.soresolves whichever is present at load time (nowebkit2gtkSONAME is hard-linked), so a single jar runs on both. - macOS needs nothing extra — WKWebView ships with the OS.
Every tagged release is published to GitHub Packages before Maven Central, and is resolvable there the moment the release build finishes — useful when a downstream project needs to build against a new version without waiting for Central to sync.
GitHub Packages requires an authenticated read even for public packages, so this channel is only practical for first-party projects and CI that already hold a token. Maven Central is the canonical channel; external consumers should use the coordinates above.
Add the repository to your pom.xml:
<repositories>
<repository>
<id>github-webliteca</id>
<url>https://maven.pkg.github.com/webliteca/swingwebview</url>
</repository>
</repositories>and a matching server entry in ~/.m2/settings.xml, where the password is
a personal access token with the read:packages scope (in GitHub Actions,
use ${{ github.actor }} and the built-in GITHUB_TOKEN instead):
<server>
<id>github-webliteca</id>
<username>YOUR_GITHUB_USERNAME</username>
<password>YOUR_PAT_WITH_READ_PACKAGES</password>
</server>The <id> values must match. Once Maven Central has synced the same
version, the repository entry can be dropped again — the artifacts are
identical.
| Platform | Heavyweight | Lightweight |
|---|---|---|
| macOS (Cocoa / WKWebView) | Full (rendering, input, resize, tab visibility) | Stub — falls back to default Swing background |
| Linux (WebKitGTK / X11) | Rendering, mouse, scroll, resize, tab switching work. Visible text-input feedback (caret blink, characters appearing as typed) is unreliable because of how GTK frame-clock and focus interact with XReparentWindow under a foreign (non-GTK) parent. |
Full — rendering + mouse (click, drag, scroll, hover) + keyboard (typing, Backspace, Delete, arrows, function keys, common modifiers) |
| Windows (WebView2) | Full (rendering, input, resize, tab visibility) on Windows 11 | Stub |
The WebViewComponent.create() factory picks the right mode for the
current platform (heavyweight on macOS / Windows, lightweight on
Linux), so most callers don't need to think about it.
The standard platform shortcut (Cmd on macOS, Ctrl on Linux /
Windows) + C / V / X / A performs Copy / Paste / Cut /
Select-All inside the embedded WebView on all platforms. A
KeyEventDispatcher installed on the component routes the shortcut to
the native editing primitive — [WKWebView copy:/paste:/cut:/selectAll:]
on macOS, webkit_web_view_execute_editing_command on Linux,
document.execCommand on Windows. Sibling Swing widgets (a
JTextField in a toolbar above the WebView, etc.) keep their default
shortcut handling — the dispatcher only fires when the user is actually
interacting with the WebView.
import ca.weblite.webview.swing.WebViewComponent;
import javax.swing.*;
import java.awt.*;
public class Demo {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
WebViewComponent wv = WebViewComponent.create();
wv.setUrl("https://example.com");
wv.setPreferredSize(new Dimension(900, 600));
JFrame frame = new JFrame("WebView Demo");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(wv, BorderLayout.CENTER);
frame.pack();
frame.setVisible(true);
});
}
}WebViewComponent.create() returns whichever implementation is best
for the current platform. Two concrete subclasses both extend
WebViewComponent:
WebViewHeavyweightComponent— embeds the native WebView as a child of the underlying heavyweight AWT peer. Renders directly to screen pixels. Native compositing means the highest fidelity and lowest overhead, but it interacts with Swing Z-order the way every heavyweight AWT component does — it paints above any overlapping lightweight Swing components in the same window (see "Heavyweight popup notes" below).WebViewLightweightComponent— renders the WebView into an offscreen surface, ships the pixels to Java, and Swing paints them into a regularJComponent. Composites cleanly with arbitrary Swing widgets and Z-order. Higher per-frame cost than heavyweight; mouse and keyboard input is forwarded from Swing.
To force a specific mode, either set the ca.weblite.webview.mode
system property to heavyweight or lightweight (case-insensitive),
or call the factory explicitly:
import ca.weblite.webview.swing.WebViewComponent;
import ca.weblite.webview.swing.WebViewComponent.Mode;
WebViewComponent wv = WebViewComponent.create(Mode.HEAVYWEIGHT);
// or Mode.LIGHTWEIGHTYou can also instantiate WebViewHeavyweightComponent or
WebViewLightweightComponent directly if you need to.
When using WebViewHeavyweightComponent, native Swing popups
(JComboBox dropdowns, JMenu, tooltips) render behind the
WebView's heavyweight peer unless you opt into heavyweight popup mode
at app start:
JPopupMenu.setDefaultLightWeightPopupEnabled(false);
ToolTipManager.sharedInstance().setLightWeightPopupEnabled(false);This makes popups appear as real OS windows that sit above heavyweight peers. Lightweight mode does not need this.
The embedded WebView does not take ownership of the host application's event loop.
The lightweight component renders WebKit into a GtkOffscreenWindow,
snapshots cairo_image_surface_t pixels at ~30Hz into a
BufferedImage, and paints that into the JComponent via
paintComponent. AWT MouseEvents and KeyEvents are translated to
GdkEvents and injected via gtk_main_do_event. Notes:
- The WebKitWebView's IM context is disabled because all input arrives
already-decoded from AWT. This means CJK / IME composition is
not available in the lightweight component on Linux today. Dead
keys and Compose key sequences (e.g.
é,ñ) work for ASCII-Latin-1 layouts but not for IME-driven layouts. - Right-click context menus and
<select>dropdowns from inside the page log agdk_window_move_to_rect: assertion 'window->transient_for'warning and don't visibly appear — WebKit tries to position them relative to a toplevel that doesn't exist in our offscreen model. Not fatal; just a missing piece of UI for those interactions. - Heavyweight popup interop is not needed in lightweight mode —
Swing components like
JComboBoxand tooltips composite over the WebView with their normal lightweight rendering.
- Linux (GTK / WebKitGTK / X11) — the WebView's GTK window is
reparented under the JAWT-managed X11 window via
XReparentWindow. A dedicated GTK pump thread drives the WebKitGTK main loop independently of AWT's X11 event loop. A 60Hzg_timeoutdrives the paint pipeline (the X11 GdkFrameClock won't pace itself on a reparented popup that has no WM relationship). Requireslibwebkit2gtk-4.0-devorlibwebkit2gtk-4.1-devpluslibxt-dev(JDK 8'sjawt_md.hpulls in X11 Intrinsics). - macOS (Cocoa / WKWebView) — the WKWebView is added as a real
subview of
NSWindow.contentView(looked up through the layer hierarchy from the JAWTwindowLayer), so WebKit's CARemoteLayer compositing engages and input dispatch goes through AppKit's normal responder chain. All input works end-to-end. - Windows (WebView2) — a child
HWNDis created under the AWT canvas HWND and anICoreWebView2Controller+ICoreWebView2are hosted inside it (modern stable WebView2 SDK). Each embedded WebView runs on its own worker thread that pumps a private message queue.WebView2LoaderStatic.libis linked statically so we ship justwebview.dll, no separateWebView2Loader.dll. The system WebView2 Runtime (part of Edge / Windows 11) provides the actual Chromium binaries. The per-user WebView2 data directory is derived from the host executable name and path, so applications installed under a protected directory such asProgram Filesstart without elevation.
The AWT focus chain and the native focus chain (AppKit responder / Win32 keyboard focus) are independent on these platforms, and the heavyweight WebView's native peer holds native focus in a way AWT doesn't observe. Two consequences are handled automatically:
- When the user clicks into the WebView, the previously-focused Swing
JTextComponent's caret is hidden (visual cue that typing now lands in the WebView). macOS hooksbecomeFirstResponderon theWKWebViewvia a runtime class swizzle; Windows hooksICoreWebView2Controller::add_GotFocus. - When the user clicks back to a Swing component in the same window,
the suppressed caret is restored and its blink timer is restarted
via a synthetic
FocusEvent.FOCUS_GAINED. On Windows we additionally force Win32 keyboard focus back to the JFrame HWND (cross-threadSetFocusviaAttachThreadInput) so subsequent keystrokes actually reach the Swing component — WebView2 otherwise keeps Win32 focus on its child HWND and steals keystrokes.
For debugging, set -Dca.weblite.webview.debugShortcut=true (Java
side) and WEBVIEW_DEBUG_SHORTCUT=1 (native side) to log the
dispatcher decisions and Win32 SetFocus calls.
The native embedding layer (src_c/webview_embed.cpp) is quiet by
default — a normal embed/launch prints no [webview-embed] lines to
stderr. Set DEBUG_WEBVIEW_EMBED=1 on the way in — for example
DEBUG_WEBVIEW_EMBED=1 java -jar your-app.jar — to restore the full
verbose trace: JAWT resolution, the JAWT_GetAWT version-mask that
succeeded, GTK reparenting, the WebKit load lifecycle, click/focus
grabs, the repaint timer, navigation, the per-frame draw#/frame-clock
instrumentation, and (on macOS) host-NSView discovery and
WKWebView subview attachment. Genuine error/failure conditions
(missing JAWT_GetAWT, dlopen/dlsym failures, a rejected JAWT
version mask, a JAWT_LOCK_ERROR, a non-X11 GdkWindow, a WebKit
load-failed, or the macOS layer-only-fallback warning) always print,
regardless of the flag. The Windows port
(windows/webview_embed.cc) already logs only on failure, so it has
no default chatter to silence.
This flag is read by the native library, so it only takes effect once you are running against a native build that includes it — the natives are produced by
build-{linux,mac,windows}.sh/ the CI release matrix rather than checked into the repo, so downstream consumers pick it up after the next native release.The macOS
ApplePersistenceIgnoreState: Existing state will not be touched …line is emitted by AppKit itself, not by this library, so it is unaffected byDEBUG_WEBVIEW_EMBED.
Four methods on WebViewComponent (and on the standalone WebView)
cover the JS-interop surface:
eval(String js)— fire-and-forget. Runs the snippet in the current document; the return value is discarded. Use for side effects (scrollTo,document.title = "...", click a hidden button).evalAsync(String js): CompletableFuture<String>— round-trips the snippet's result back to Java. The future resolves with theJSON.stringify'd return value (undefinedbecomes"null"; returnedPromises are awaited). JS-side failures (synchronousthrow, Promise rejection,JSON.stringifyTypeError) complete the future exceptionally with aJavaScriptEvalException. The snippet runs inside an IIFE, so usereturnto yield a value — a bare expression is not the IIFE's return.addJavascriptCallback(String name, JavascriptCallback cb)— exposes a fire-and-forget Java callback atwindow.<name>(arg)for the page to call. The callback returns nothing to JS. Use when the page initiates the conversation, or when a long-lived JS subscription needs to push events to Java.addJavascriptFunction(String name, JavascriptFunction fn)— exposes a value-returning Java function atwindow.<name>(arg). In the page it returns a Promise:const r = await window.<name>(arg). No JavaScript glue — the Java side is just a lambda. The library runs the (synchronous) handler on a background thread, so it can block safely without freezing the UI or deadlocking the engine UI thread against the EDT — the reason this exists instead of a synchronous, value-returningaddJavascriptCallback. ACompletableFuture<String>-returning overload (AsyncJavascriptFunction) covers inherently-async work. Results are strings (return JSON text for structured data); a thrown exception rejects the page-side Promise.
WebViewComponent wv = WebViewComponent.create();
wv.setUrl("https://example.com");
// ...add to JFrame and show...
// Ask the page for its current scroll position once it loads.
wv.evalAsync("return [window.scrollX, window.scrollY];")
.thenAccept(json -> System.out.println("scroll = " + json));
// Prints e.g. "scroll = [0,240]"
// Await a Promise: the future resolves with the fetched body length.
wv.evalAsync(
"return fetch('/health').then(r => r.text()).then(t => t.length);"
).thenAccept(json -> System.out.println("body length = " + json));
// JS error → future completes exceptionally.
wv.evalAsync("return missing.value;").exceptionally(t -> {
Throwable cause = t.getCause(); // CompletionException wraps it
if (cause instanceof JavaScriptEvalException) {
System.err.println("page said no: " + cause.getMessage());
}
return null;
});
// Expose a value-returning Java function to the page — no JS glue.
wv.addJavascriptFunction("reverse", (String arg) ->
new StringBuilder(arg).reverse().toString());
// in the page: const r = await window.reverse("abc"); // "cba"Threading. On WebViewComponent (both heavyweight and lightweight)
future continuations land on the Swing EDT, so a .thenAccept(...) can
touch Swing state directly. On the standalone WebView continuations
run inline on the WebView's native UI thread — there's no Swing in the
standalone path; wrap with
.thenAcceptAsync(continuation, SwingUtilities::invokeLater) if you
need EDT delivery there.
Lifecycle. Calling evalAsync before the component is displayed
(or on the standalone WebView before show(), or after the window
closes) returns an already-failed future whose cause is an
IllegalStateException — no native call is made. See
demos/WebViewAsyncEvalDemo/
for a runnable example.
Pages can call window.alert, window.confirm, window.prompt, and
they can include <input type="file"> elements whose click opens a
file picker. WebViewComponent.setDialogHandler lets the host
application customise — or fully suppress — what shows up:
wv.setDialogHandler(new WebViewDialogHandler() {
@Override public boolean confirmOpened(WebViewConfirmEvent e) {
return JOptionPane.showConfirmDialog(
frame, e.message(), "Confirm",
JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION;
}
});- Default behaviour. When no handler is installed (the initial
state), every dialog kind shows a Swing dialog —
JOptionPanefor alert / confirm / prompt,JFileChooserfor file picker — modal to the hostJFrameresolved viaSwingUtilities.getWindowAncestor(component). Override individual methods to customise specific kinds; un-overridden methods fall through to the Swing defaults. - Drop mode for headless tests. Pass
null:wv.setDialogHandler(null)installs an internal drop handler that returns the JS-spec cancel values synchronously without UI (alertno-op,confirm→false,prompt→null, file picker → empty list). Required for unit tests in headless environments. To reset to the framework default, passWebViewDialogHandler.DEFAULTexplicitly —nullis NOT a reset. - Threading. Handler methods run on the Swing EDT, marshaled
from whatever native thread fired the dialog. Calling
wv.evalAsync(js).get()from inside a handler deadlocks (both calls park on the EDT); use.thenAccept(...)instead, or pre-compute the value before the dialog opens. - Platform coverage (current). macOS heavyweight (WKWebView)
routes all four dialog kinds through the handler (STORY-004-001).
Linux WebKitGTK routes all four kinds through the handler in both
heavyweight and lightweight modes via the
script-dialogandrun-file-choosersignals (STORY-004-002). Windows WebView2 routes alert / confirm / prompt (and before-unload) through the handler via theScriptDialogOpeningevent combined withput_AreDefaultScriptDialogsEnabled(FALSE)(STORY-004-003). On Windows,<input type="file">continues to use the OS-native Common Item Dialog — WebView2 exposes no public hook for the file picker, sofilePickerOpenednever fires on Windows. On Windows,frameUrl()equalspageUrl()for now (top-level only) because theScriptDialogOpeningevent args do not expose a separate frame URL. - Linux file-picker
accept-extension limitation. On Linux, theWebViewFilePickerEvent.acceptedExtensionslist is always empty even when the page wrote<input accept=".png,.jpg">— WebKitGTK exposes the extension filter as an opaqueGtkFileFilterrather than the original extension strings. The page's MIME-type hints (accept="image/png"etc.) are surfaced viaacceptedMimeTypes; the page's own client-sideacceptvalidation continues to work.
See demos/WebViewDialogDemo/
for a runnable example that exercises all four dialog kinds in each
of the three handler modes (default, custom, drop).
When the page starts a file transfer — a click on <a href="…" download>,
a navigation whose response carries Content-Disposition: attachment, or
a navigation to a body the engine will not render inline —
WebViewComponent.setDownloadHandler lets the host decide where the bytes
land and watch the transfer through.
wv.setDownloadHandler(new WebViewDownloadHandler() {
@Override
public File downloadRequested(WebViewDownloadEvent e) {
// e.suggestedFileName() is already a safe leaf name.
return new File(myDownloadsDir, e.suggestedFileName());
}
@Override
public void downloadProgress(WebViewDownloadProgressEvent e) {
bar.setValue(e.sizeKnown() ? (int) (e.fraction() * 100) : 0);
bar.setIndeterminate(!e.sizeKnown());
}
@Override
public void downloadCompleted(WebViewDownloadCompleteEvent e) {
if (e.success()) notifyUser("Saved " + e.destination());
else notifyUser("Download failed: " + e.failureReason());
}
});- The engine writes; you choose where. The handler answers one question — which file — and then observes. Java is never handed a stream: the engine already holds the connection, the cookie jar, and the authentication state needed to finish the transfer.
- Default behaviour. With no handler installed,
downloadRequestedshows aJFileChoosersave dialog anchored on the hostJFrameand pre-filled with the server-suggested name, confirming before it replaces an existing file. The two notification methods do nothing. - Returning
nullrefuses the download. Nothing is written anywhere — including into the platform's own default downloads folder, which is the behaviour this channel exists to take over. setDownloadHandler(null)is NOT a reset. It installs a drop handler that refuses every download with no UI — the headless-test and explicit-opt-out path. PassWebViewDownloadHandler.DEFAULTexplicitly to restore the save dialog. Readingnullas "reset to the default" would ship an app that silently refuses every download.- Threading. All three methods run on the EDT, but
downloadRequestedis synchronous (invokeAndWait, with the engine's thread waiting) while progress and completion are fire-and-forget (invokeLater). As withWebViewDialogHandler, callingwv.evalAsync(js).get()from insidedownloadRequesteddeadlocks. - Progress is lossy; completion is not. Progress events are
coalesced so at most one per download is queued on the EDT at a time —
a 100 MB transfer reporting every 8 KB would otherwise queue ~12,800
EDT tasks — so a handler sees the latest counts rather than every
chunk, and must not do expensive work there.
downloadCompletedis delivered exactly once per download, whatever the backend emits. - Unknown size is
-1, never0. UsesizeKnown()rather than testing for a magic value, so a progress bar can tell "no bytes yet, 10 MB expected" from "some bytes, size unknown". - Filenames are sanitised for you.
WebViewDownloadEvent.suggestedFileName()arrives off the wire and is attacker-controlled, so it reaches the handler already reduced to a bare leaf name: no separators of either flavour, no.., no control characters, no characters illegal on Windows, no trailing dots or spaces, no reserved Windows device name (CON,NUL,LPT1…), bounded to 255 characters, and never empty. A handler may join it onto a directory of its own choosing, but must not join it onto a parent path the page can influence. - Several downloads at once. Every event carries an
id()that is stable from the destination decision through to the terminal report, so progress can be attributed to the download that produced it. The destinationFileis not a usable key: it is unknown at request time and can repeat across sequential downloads. - Platform coverage (current). macOS routes downloads through the
handler by adopting
WKDownloadDelegateand answeringWKNavigationResponsePolicyDownload(Canvas 23) — requires macOS 11.3+; on older systemsWKDownloaddoes not exist, downloads behave as they did before, and the handler is not invoked. Linux WebKitGTK routes them in both heavyweight and lightweight modes, and in popups, via the web context'sdownload-startedsignal (Canvas 24). Windows WebView2 routes them viaICoreWebView2_4::add_DownloadStartingand suppresses the download flyout (Canvas 25) — requires a runtime exposingICoreWebView2_4; on an older runtime WebView2 keeps its built-in handling and the handler is not invoked. - Not covered. Pausing, resuming, or cancelling a download after it has started; a built-in downloads list or history; resuming interrupted transfers across process restarts.
See demos/WebViewDownloadDemo/
for a runnable example that serves five download shapes from a loopback
server and exercises all three handler modes.
The embedded engines (WKWebView, WebKitGTK, WebView2) do not
give an embedding app the browser "offer to save this password / autofill
it next time" experience — that is a browser-privileged feature the raw
engine withholds. WebViewComponent provides its own password manager
instead: an injected script detects login-form submissions and the library
shows a Swing "Save password?" prompt; on approval the credential is
written to the OS-native secret store; on a later page load a stored
credential for the same origin is auto-filled.
WebViewComponent wv = WebViewComponent.create();
// Enabled by default. Turn it off with:
wv.setPasswordManagerEnabled(false);
// Programmatic access (works regardless of the enabled flag):
wv.saveCredential(new WebViewCredential("https://example.com", "alice", "s3cret"));
Optional<WebViewCredential> c = wv.getCredential("https://example.com");
List<WebViewCredential> everything = wv.getAllCredentials(); // all origins
wv.deleteCredential("https://example.com", "alice");
// Require a confirmation (or an OS biometric check) before autofill:
wv.setFillPasswordHandler(WebViewFillPasswordHandler.CONFIRM);Key points:
- Origin keying. Credentials are keyed by page origin =
scheme + host + port (the default port is implied by the scheme, so
https://example.comandhttps://example.com:443are the same origin). Autofill is exact-origin only — a credential for one origin is never offered on another (httpvshttps, a different port, or a different host are all distinct). - OS-native storage. Passwords live only in the OS secret store —
macOS Keychain, Windows Credential Manager, and Linux
libsecret / Secret Service (GNOME Keyring, KWallet, or any
freedesktop Secret Service provider) are all wired. On Windows the
credentials are stored in the library's own Credential-Manager
namespace (per-user, DPAPI-protected), not the Edge profile, and Edge's
built-in password autosave is disabled so it does not compete with this
manager. On Linux libsecret is loaded at runtime (
libsecret-1.so.0); where no Secret Service provider is available the store degrades to a no-op. The library never writes a plaintext credential file and never logs a password. - Overridable seams.
setCredentialStore(WebViewCredentialStore)swaps the backing store (e.g.InMemoryCredentialStorefor tests);setSavePasswordHandler(WebViewSavePasswordHandler)replaces the "Save password?" policy (return a disposition programmatically for headless use). Passingnullto either restores the default. Both getters never returnnull. - Autofill consent. By default a stored credential is filled
silently on page load.
setFillPasswordHandler(WebViewFillPasswordHandler)gates that: installWebViewFillPasswordHandler.CONFIRMfor a browser-style "Use the saved password for<origin>?" prompt, or your own handler that performs an OS biometric / re-authentication check (Touch ID, Windows Hello) and returnsDONT_FILLto decline. The event handed to the handler carries only the origin and username — never the password. Passingnullrestores the silent-autofill default; the getter never returnsnull. The consent handler gates the automatic page-load autofill only — the programmaticgetCredential/getCredentials/getAllCredentialsreads are trusted host calls and are never gated. - Managing saved passwords.
getAllCredentials()enumerates every stored credential across all origins (most-recently-saved first) — the primitive you need to build a Chrome-style "manage saved passwords" screen on top of the OS-native store, combined withsaveCredential(add/edit) anddeleteCredential(remove). Like the origin-scoped reads it returns credentials to host code only; page JavaScript has no path to any stored credential. - Security note. Once a credential is auto-filled it lives in the page DOM and is readable by any script running on that page — exactly the same exposure as a browser's autofill. The library only ever fills the single origin-matched credential it chose to send.
- Coverage: all three platforms. Automatic capture / autofill and the native secret store are wired on macOS (Keychain), Windows (Credential Manager), and Linux (libsecret, both heavyweight and lightweight). Where a platform has no available secret store — notably a headless or keyring-less Linux session — the store degrades to a graceful no-op: page load and form submission still work, and the programmatic API simply reports nothing stored.
Known limitation: multi-step / identifier-first login flows (username and password on separate pages, e.g. some Okta configurations) are captured best-effort per page; cross-page correlation is not guaranteed.
See demos/WebViewPasswordDemo/
for a runnable example exercising capture, autofill, and the programmatic
API in both the Keychain-backed and in-memory store modes.
demos/WebViewPasswordOptInDemo/
shows a Chrome-style opt-in fill — silent autofill suppressed, an
account chooser under the focused login field, and a simulated unlock
before the password is filled.
Pages can call window.open(url, name, features) or click a link / form
with target="_blank". WebViewComponent.setPopupHandler lets the host
application allow, observe, or block those popups:
wv.setPopupHandler(new WebViewPopupHandler() {
@Override public boolean popupRequested(WebViewPopupEvent e) {
return e.targetUrl().startsWith("https://"); // allow only https
}
@Override public void popupOpened(WebViewPopupEvent e) {
System.out.println("popup: " + e.targetUrl());
}
});- Native-owned window. When a popup is allowed the native engine
creates the child web view linked to the opener and hosts it in a
fresh native top-level window that the engine sizes, shows, and
destroys. The opener linkage is what makes OAuth "sign-in with popup"
flows work — the popup calls
window.opener.postMessage(...)thenwindow.close(), which only succeed when the popup is a real linked view rather than an independent tab. The handler only decides policy (popupRequested) and observes the lifecycle (popupOpened/popupClosed); it does not open, host, or size the window. - Default behaviour. With no handler installed every popup is allowed.
- Blocking popups. Pass
null:wv.setPopupHandler(null)blocks all popups (window.openreturnsnull) — the pre-feature behaviour, available as an explicit opt-out. To reset to the framework default (allow), passWebViewPopupHandler.DEFAULTexplicitly —nullis NOT a reset. - Threading.
popupRequestedruns on the native UI thread, synchronously and off the EDT (the platform popup callback must return the allow/deny decision before yielding to the browser engine); keep it fast, thread-safe, and free of Swing access.popupOpened/popupClosedare asynchronous notifications delivered on the EDT. - Platform coverage. All three engines open native, opener-linked
popup windows: macOS heavyweight (WKWebView) via the
WKUIDelegate createWebViewWithConfiguration:/webViewDidClose:pair (Canvas 15); Linux heavyweight and lightweight (WebKitGTKcreate/ready-to-show/closesignals, Canvas 16); and Windows (WebView2NewWindowRequested+ the child'sWindowCloseRequested, Canvas 17).setPopupHandler(null)blockswindow.openon every platform.
See demos/WebViewPopupDemo/ for a
runnable example that exercises the allow / custom / block modes.
By default an allowed popup opens in a native window the engine owns
(above). A tabbed browser usually wants the popup to appear as a new
tab instead. Blocking the native window and re-opening
e.targetUrl() with setUrl(url) does not work for that: setUrl
issues a GET, so a <form method="post" target="…"> popup loses its
POST body and the re-opened page is no longer opener-linked. The POST
body is not exposed to the popup channel on any engine, so the request
cannot be replayed from Java.
Instead, adopt the engine's own opener-linked child — the view
WebKit already drove the original request (POST verb + body) into — into
a WebViewComponent you supply:
wv.setPopupHandler(new WebViewPopupHandler() {
// 1. Decide ADOPT on the native UI thread (synchronous, off the EDT).
@Override public PopupDisposition popupDisposition(WebViewPopupEvent e) {
return PopupDisposition.ADOPT; // not a native window
}
// 2. On the EDT, host the retained child in a new tab.
@Override public void popupAdoptable(WebViewPopupEvent e, long popupId) {
WebViewComponent tab = WebViewComponent.adoptPopup(popupId);
myTabbedPane.addTab("Popup", tab); // realizing it adopts the child
}
});- POST + opener preserved. The adopted component reuses the engine's
child, so
<form method="post">popups keep their body andwindow.opener/postMessagekeep working — the same guarantee the native-window path has, now in a tab. - Two-phase, no flash.
popupDispositionreturnsADOPTsynchronously on the native UI thread (same rules aspopupRequested: fast, thread-safe, no Swing). The engine creates the child but shows no window; it firespopupAdoptableon the EDT, where you build the tab and callWebViewComponent.adoptPopup(popupId). Adoption happens when that component is realized. - Backward compatible.
popupDispositiondefaults to deriving frompopupRequested(true → NATIVE_WINDOW,false → BLOCK), so existing handlers andsetPopupHandler(null)are unchanged. - Adopt-once / reclaim. A
popupIdmay be adopted once; an unknown or already-adopted id throws (IllegalArgumentException/IllegalStateException). A child decidedADOPTbut never adopted is reclaimed when the opener is disposed or after a bounded grace period. - Platform coverage. The reference backend is macOS heavyweight
(WKWebView; the retained child is reparented into the tab's
NSView). Linux (WebKitGTK) and Windows (WebView2) adoption, and lightweight / offscreen adoption, are follow-up work; on those the native adopt is not yet wired, soadoptPopupthere fails fast rather than silently losing the popup. The native adoption code ships pattern-faithful to the existing popup handlers but must be validated on-device (no native toolchain runs in the code-generation sandbox).
Some web apps gate on the User-Agent. WKWebView's default UA omits the
Version/… Safari/… tokens, so UA-sniffing sites can reject the embedded
WebView. Override it with setUserAgent — this changes the actual HTTP
User-Agent request header (not just the JS-visible
navigator.userAgent):
WebViewComponent wv = WebViewComponent.create();
wv.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15");
wv.setUrl("https://example.com/"); // first request carries the custom UA- Reset.
setUserAgent(null)orsetUserAgent("")restores the engine default.getUserAgent()returns the override, ornullwhen the default is in force. - Timing. Called before display, it applies to the first request. Called after display, it applies to the next navigation (engines don't rewrite the in-flight request for the current page).
- Popups inherit it. A browser-initiated popup (
window.open,target="_blank", or a POST that targets a new window) — whether adopted into aWebViewComponenttab or opened in a native window — carries the opener's custom UA on its first request. When the opener has no override, the popup uses the engine default. - Platform coverage. macOS
WKWebView.customUserAgent, Linux WebKitGTKwebkit_settings_set_user_agent, Windows WebView2ICoreWebView2Settings2::UserAgent. The native setters ship pattern-faithful but must be validated on-device (confirm the header via an echo endpoint).
One static UA cannot satisfy two sites that want opposite things. Slack
rejects the engine's own UA as an unsupported browser and wants a mainstream
desktop Chrome string; Google's sign-in, presented that same Chrome UA by a
WebKit engine, scores it as a spoof — the claimed Chrome build has no
navigator.userAgentData, sends no Sec-CH-UA client hints, and carries a
WebKit TLS fingerprint — and answers with a CAPTCHA that cannot be passed at
any score.
setUserAgentResolver picks the UA per destination instead:
WebViewComponent wv = WebViewComponent.create();
wv.setUserAgent(SAFARI_UA); // the default for everything
wv.setUserAgentResolver(url ->
url.contains("://app.slack.com/") ? CHROME_UA : null); // null = fall through
wv.setUrl("https://app.slack.com/client"); // first request carries CHROME_UA- Precedence. The resolver's answer wins when it is non-null and
non-blank; otherwise the static
setUserAgentvalue applies; otherwise the engine default. Anullor blank return means fall through, not "use the engine default" — a resolver can never force the engine default over a static override. - When it is consulted. At three points, each a navigation that is about
to start and that the library controls: before a view's initial navigation,
before any
setUrlon a live view, and before a browser-initiated pop-up child's first navigation — keyed on the pop-up's own target URL. That last one is the point of the feature: an OAuth sign-in popped out of a site that needs a spoofed UA lands on an identity provider that penalises exactly that spoof. When a resolver is installed it supersedes the opener-copy described under Popups inherit it above; with no resolver, opener-copy is unchanged. - Limitation. This is not per-request switching. A server-side redirect
that crosses hosts during a navigation keeps the UA that navigation
started with. Intercepting every navigation was considered and rejected:
WebKit does not reliably expose
NSURLRequest.HTTPBodytodecidePolicyForNavigationActionfor form POSTs, so the cancel-set-UA-reissue pattern would silently drop OAuth form-post bodies. - Threading. The resolver runs on the engine UI thread immediately before
the navigation it governs, so keep it fast and non-blocking — a host-suffix
check, not a network call. A resolver that throws is treated as a
nullreturn and can never break a navigation. - Platform coverage. The pop-up path upcalls the resolver natively on
macOS, Linux and Windows; the other two points resolve in Java. As with the
UA setters, the native upcall ships pattern-faithful but must be validated
on-device (
WebViewAdoptPopupDemo's Resolver toggle plus thehttps://httpbin.org/user-agentecho).
When a site renders blank because a stale (or poisoned) cached resource is
replayed on every load — the classic symptom where the Web Inspector's
"Disable Caches" makes it work — purge the engine's HTTP resource cache
with clearCache(), then reload:
WebViewComponent wv = WebViewComponent.create();
// … after the page has loaded blank from cache …
wv.clearCache();
wv.eval("location.reload()"); // refetch from the network- Resource cache only — login survives.
clearCache()drops the disk + memory HTTP cache and nothing else: cookies, local storage, IndexedDB, and service-worker registrations are left intact, so a session reached through a logged-in link stays logged in. It reaches a cache that page JavaScript (caches.delete()/ unregistering a service worker) cannot. - Timing. The native purge is asynchronous and runs on the engine's UI
thread; trigger a navigation after it (re-
setUrlor aneval("location.reload()")) to force the refetch. A no-op when no native peer is attached — safe to call headless. - Platform coverage. macOS
WKWebsiteDataStoredisk+memory cache types, Linux WebKitGTKwebkit_web_context_clear_cache, and Windows WebView2ICoreWebView2Profile2::ClearBrowsingData(DISK_CACHE)— all three implemented and compiled by CI's cross-platform native build. The purges should still be spot-checked on-device (confirm a previously-cached resource is re-requested, and cookies/login survive).
Use getCookies to transfer a browser-authenticated session to an HTTP client
without exposing credentials to page JavaScript:
wv.getCookies("https://example.com/")
.thenAccept(cookieHeader -> request.header("Cookie", cookieHeader))
.exceptionally(error -> {
error.printStackTrace();
return null;
});The future completes on the Swing event-dispatch thread. Its result contains
only cookies applicable to the requested URL in HTTP Cookie header syntax
(name=value; name2=value2). Unlike document.cookie, the native browser API
also returns HttpOnly cookies. Treat the returned value as a credential: do
not log it or include it in exception messages.
The implementation uses WKHTTPCookieStore on macOS,
WebKitCookieManager on Linux, and ICoreWebView2CookieManager on Windows.
The WebView must be displayed and its native peer attached before calling this
method. Cookie queries are asynchronous and do not block the Swing thread.
Matching cookies are ordered longest path first. The lightweight (offscreen)
component supports getCookies on Linux; with a native library that predates
this method the future fails with UnsupportedOperationException. Run
run-{linux,mac}-cookie-demo.sh or run-windows-cookie-demo.bat to check a
platform (see demos/WebViewCookieDemo/README.md).
Print the page a component shows to a PDF file, with no print dialog, at the page size and margins you choose:
WebViewComponent wv = WebViewComponent.create();
// … after the page (and any layout script it runs) has finished …
wv.printToPdf(new File("report.pdf")) // Letter, no margins
.thenAccept(f -> System.out.println("Wrote " + f))
.exceptionally(t -> { System.err.println(t.getMessage()); return null; });
wv.printToPdf(new File("report-a4.pdf"), PdfOptions.a4().withMargins(0.5));- The PDF is the page. Sizes and margins are in inches; backgrounds print
by default; the engine's own headers and footers are always off and the scale
is 1. Pages laid out with CSS page boxes (
@page { size: 8.5in 11in; margin: 0 }) come out one for one. - Asynchronous, completes on the EDT. The future completes with the file
once it is written, or fails with an
IOExceptionsaying why: the folder does not exist, the native library or engine runtime cannot print to PDF, the component is not attached yet, or it was closed first. Prints on one component run one after another. - Capability check.
WebViewComponent.isPdfPrintingSupported()isfalseagainst a native library built before this feature. - Platform coverage. Linux (lightweight) prints through WebKitGTK's
WebKitPrintOperationto GTK's "Print to File" printer; macOS (heavyweight, macOS 11+) through-[WKWebView printOperationWithPrintInfo:]; Windows (heavyweight) through WebView2'sICoreWebView2_7::PrintToPdf. Seedemos/WebViewPdfDemo/. On Linux the heavyweight component refuses with "PDF printing on Linux needs the lightweight WebView component."
Serve an application's own pages from a URL scheme of its own, such as
demo://app/index.html, answered in Java. There is no local HTTP server, no
open port and no token:
// Before the first WebView is created:
if (WebViewSchemes.isSupported()) {
WebViewSchemes.register("demo", (request, responder) -> {
if (request.url().equals("demo://app/index.html")) {
responder.respond(WebViewSchemeResponse.ok("text/html",
"<h1>Hello</h1>".getBytes(StandardCharsets.UTF_8)));
} else {
responder.respond(WebViewSchemeResponse.text(404, "Not found"));
}
});
}
WebViewComponent wv = WebViewComponent.create();
wv.setUrl("demo://app/index.html");- Register before the first WebView. Engines fix their schemes when the
first WebView is created, so a later
registeris refused with "Custom schemes must be registered before the first WebView is created." A scheme name is 2–32 characters: a letter first, then letters, digits,+,-or.. The web's own schemes (http,https,file,data,blob,about,javascript,ws,wss,ftp) cannot be registered. - The handler contract. Handlers run off the UI thread, on daemon threads
named
webview-scheme-N. The request carries the method, the full URL, the headers (header(name)ignores case) and the body. Answer once, from any thread, whenever you are ready: later answers are ignored. A handler that throws gives the page a 500, and one that has not answered after 30 seconds gives a 504. A request to a scheme with no handler gets a 404. - Headers.
Content-Lengthis computed for you. A missingContent-Typebecomesapplication/octet-stream, andAccess-Control-Allow-Originis set to the request's own origin, so scripts ondemo://app/canfetchotherdemo://app/…addresses. - Caps. A request body over 16 MB is answered 413 without calling the handler; a response body over 64 MB is replaced by a 500. Bodies arrive whole: there is no streaming.
- Capability check.
WebViewSchemes.isSupported()isfalseagainst a native library without this feature, andregisterthen fails with "Custom URL schemes are not available in this version of the native library". - Platform coverage. macOS (heavyweight) through a
WKURLSchemeHandleron each view's configuration, popups included. Linux (lightweight and heavyweight) through a URI scheme on WebKitGTK's default web context, registered as secure and CORS-enabled, which every view and popup shares. Windows (heavyweight) through custom scheme registrations on the WebView2 environment (secure, with a host, accepting requests from pages on the same scheme) andWebResourceRequested, on every view and popup. The standaloneWebViewwindow is not covered. Seedemos/WebViewSchemeDemo/. - Linux notes.
- Request bodies need WebKitGTK 2.40 or newer. On older engines the handler
gets an empty body and
bodyAvailable()isfalse. - Response status codes and headers need WebKitGTK 2.36 or newer. On older
engines a 2xx answer keeps only its body and
Content-Type, and any other status reaches the page as a network error. - WebKitGTK does not report abandoned requests, so Linux never cancels one: an unanswered request ends at the 30-second timeout.
- Schemes are registered on the default web context, which the standalone
WebViewwindow also uses, but that window remains unsupported.
- Request bodies need WebKitGTK 2.40 or newer. On older engines the handler
gets an empty body and
- Windows notes.
- WebView2 does not report abandoned requests, so Windows never cancels one: an unanswered request ends at the 30-second timeout.
- A WebView2 Runtime too old for custom scheme registration cannot load pages from the scheme; the evergreen Runtime on current Windows can.
- Do not combine the standalone
WebViewwindow with registered schemes in one process: WebView2 may refuse the second environment because its options differ.
See demos/WebViewHeavyweightDemo/
for a working example that exercises both heavyweight and lightweight
modes side-by-side, plus interaction with surrounding Swing widgets
(JComboBox dropdowns, tab switching). One-shot launcher scripts
(run-mac-demo.sh, run-linux-demo.sh, run-windows-demo.bat) live
at the project root.
Additional demos:
demos/WebViewContextMenuDemo/— exercises the right-click context-menu API: target descriptor, link / image / editable / selection cases, and thesetDefaultContextMenuEnabledoverride.demos/WebViewAsyncEvalDemo/— exercisesevalAsync(String): primitive / object / Promise /undefinedresults, synchronous throws and Promise rejections surfacing asJavaScriptEvalException, concurrent in-flight calls, and EDT delivery of continuations.demos/WebViewAsyncCallbackDemo/— exercisesaddJavascriptFunction(...): value-returning JS→Java functions (sync handlers run off-thread, asyncCompletableFuturehandlers, errors rejecting the page Promise) with no JavaScript glue.demos/WebViewDialogDemo/— exercises the newWebViewDialogHandlerAPI: default Swing dialogs (alert/confirm/prompt/ file picker), a custom handler returning programmatic answers, and thesetDialogHandler(null)drop mode for headless tests.demos/WebViewPasswordDemo/— exercises the built-in password manager: login-submission capture + "Save password?" prompt, autofill on reload, and the programmaticsaveCredential/getCredential/deleteCredentialAPI, in both the OS-Keychain and in-memory store modes.demos/WebViewPdfDemo/— exercisesprintToPdf: a two-page report with a full-bleed cover printed as Letter and as A4 with margins, and a print into a missing folder (run-*-pdf-demo).demos/WebViewSchemeDemo/— serves a two-file page from thedemo://scheme in Java: a script, a POST echoed as JSON, a slow answer, a failing handler and a popup (run-*-scheme-demo).
git clone https://github.com/webliteca/swingwebview
cd swingwebview
mvn -DskipTests package
This produces target/webview-1.0-SNAPSHOT.jar. The build targets
Java 8 bytecode (maven.compiler.source / maven.compiler.target =
1.8 in pom.xml); it works on JDK 8 and any newer LTS. Pass
-Dmaven.compiler.release=8 if you want strict Java 8 API checking
when building on JDK 9+.
The native libraries are not checked into git. Locally, you build
them for your own platform and they get bundled into your
target/*.jar. For the Maven Central release, the
.github/workflows/maven-release.yml workflow builds all 6
platform+arch combinations (linux_64, linux_arm64, osx_64,
osx_arm64, windows_64, windows_arm64) on matching GitHub-hosted
runners and merges them into a single jar before publishing.
To build for your local platform:
- Run
build-mac.sh/build-linux.sh/build-windows.shon the matching platform. These compile the native sources and drop the binaries intonatives/<platform>/, which Maven then picks up as a resource duringmvn package. Thenatives/directory is gitignored. - Mac and Linux native sources are under
src_c/. Windows native sources are underwindows/. - On Windows you need Visual Studio installed (VS 2019 works; earlier
versions likely do too). The
build-windows.shscript runs under git bash.
A locally-built jar will only contain the native lib for whichever platform you ran the build on. The cross-platform fat jar comes only from the CI release.
MIT
- This library by Steve Hannah
- Original webview library by Serge Zaitsev