XUL-J bridge · Java Swing

java-bridge

Serve an existing, unmodified Swing application to browsers. The bridge streams live Swing windows as XUL-J operations and replays clicks and edits from the browser on the real components, on the Event Dispatch Thread. It needs only the JDK (com.sun.net.httpserver) and runs on Java 8 or later.

Status: prototype. Tested end to end with OpenJDK 17 under Xvfb (18 checks covering dialogs, file transfers, menus, selection, context menus, theming and session isolation, plus an agent driving the demo through MCP). There is no authentication.

Quick start

On a desktop

java -Djava.security.manager=allow -jar xulj-swing.jar \
     --jar Inventory.jar --frame com.acme.MainFrame --host localhost

Open http://localhost:8093/. Every browser tab gets its own MainFrame.

On a server, with Docker (JDK 17 + Xvfb)

Swing needs a display, so on a server it runs under a virtual one. The build embeds the browser client from the base repository, so clone both side by side:

git clone https://github.com/xul-j/xul-j
git clone https://github.com/xul-j/java-bridge && cd java-bridge
docker build -t xulj-jdk docker
./build.sh        # → out/xulj-swing.jar, out/LegacyInventory.jar (Java 8 bytecode)
docker run -d --init --name xulj-swing -p 8093:8093 -v "$PWD/out":/app:ro xulj-jdk \
  xvfb-run -a java -Djava.security.manager=allow -jar /app/xulj-swing.jar \
  --jar /app/LegacyInventory.jar --frame legacy.InventoryFrame --port 8093

Without Docker: javac --release 8 -d classes $(find src -name '*.java'), copy index.html, xulj.js and xul.css from xul-j/public into classes/public/, and jar cfe xulj-swing.jar org.xulj.bridge.Launcher -C classes ..

Command line

java -Djava.security.manager=allow -jar xulj-swing.jar --jar App.jar
     (--frame window.Class | [--main main.Class]) [--port 8093] [--host 0.0.0.0] [-- app args]
OptionMeaning
--jarThe application jar, loaded in its own class loader (optional if the app is already on the classpath).
--framePer-session mode: a java.awt.Window subclass with a no-argument constructor, created once per browser session.
--mainShared mode: the class whose main() is run once. It defaults to the jar's Main-Class.
--port, --hostWhere to listen (default 0.0.0.0:8093). Use --host localhost unless the machine is behind your own access control.
-- …Arguments passed to the app's main() in shared mode.

Session modes

ModeBehaviourGood for
Per session (--frame)Each browser session gets a fresh window instance. Sessions are isolated, and idle ones close after 10 minutes.Line-of-business apps used by several people at once
Shared (--main)The app starts normally, once. Every viewer sees and drives the same windows, like a shared screen. When the app exits, every viewer is told.Apps that need their main(), dashboards, kiosks, single-user tools

Embedding

import org.xulj.bridge.Host;

public static void main(String[] args) throws Exception {
    Config.load();                                     // your startup code still runs
    Host.Options opt = new Host.Options();
    opt.port = 8093;
    opt.host = "127.0.0.1";
    new Host(() -> new MainFrame(), opt).start();      // per session
    // Host.shared(opt).start(); then create your windows as usual   // shared
}

Host.Options also has tickMs (default 50), sessionTtlMs (default 10 minutes) and log.

How it works

  1. Windows. Windows are realized on a (virtual) display and placed off-screen, so Swing lays them out for real. Windows the app opens later go to the right session through their owner chain (a dialog owned by a session's frame belongs to that session). EXIT_ON_CLOSE frames are switched to DISPOSE_ON_CLOSE.
  2. Rendering and diffing. Every tickMs, on the EDT, the bridge builds a virtual XUL-J tree and sends only what changed. This is the same reconciliation algorithm as net-bridge, so both bridges produce identical kinds of streams.
  3. Intents. An intent is validated with invokeAndWait (unknown target 404, disabled 409), then run with invokeLater, so a modal dialog never blocks the HTTP server. Buttons are pressed with doClick, and check boxes and radio buttons are clicked rather than set, so ActionListeners run as they would for a user. Text goes through setText, combos through setSelectedIndex, and spinners through setValue with the model's own number type.
  4. Ids. Ids come from the app's own field names (private JButton addButton → addButton), then component names, then button labels (Import CSV… → importCsv). Commands are cmd_<id>.

Layout

SwingXUL-J
BorderLayoutvbox: north, then an hbox of west / center (flex) / east, then south
FlowLayouthbox, with spacers for RIGHT and CENTER alignment
BoxLayouthbox or vbox; glue becomes a spacer, and scroll panes, tables, lists and text areas grow
GridLayout / CardLayoutone hbox per row with equal cells / stacked cards (inactive ones hidden)
GridBagLayoutRows from the real bounds. weightx + fill becomes flex, weighty + fill makes the row grow, and an EAST anchor right-aligns.
GroupLayout, null layout, othersRows from the real bounds. Cells are sized up to the next one, which keeps label columns aligned. Controls that span the container stretch, and controls hugging the right edge are right-aligned.
JSplitPane, JTabbedPane, JScrollPaneTwo panes sized by the divider; tabbox; its view
Panel with a TitledBordergroupbox

Component mapping

SwingXUL-J
JFrame, JDialogwindow (modal for modal dialogs; Escape closes them)
JButtonbutton + command (the root pane's default button is primary; the mnemonic becomes Alt+key)
JTextField, JFormattedTextField, JPasswordField, JSpinnertextbox (password)
JTextArea, JEditorPanetextbox multiline
JCheckBox, JRadioButton, JToggleButtoncheckbox
JComboBoxmenulist
JTable, JList, JTreetree with a row source (JTree rows indented by depth)
JProgressBar, JSliderprogressmeter
JToolBartoolbar
JLabellabel (HTML labels reduced to text)
anything elsea muted [ClassName] placeholder

JMenuBar becomes a menubar and every JMenu a menu, including submenus. Separators become menuseparator, and items become menuitem. JCheckBoxMenuItem and JRadioButtonMenuItem show their check state. A menu's mnemonic opens it (Alt+F), and item accelerators are shown and bound. An item's own mnemonic is not a global shortcut, just as on the desktop. Menus open and close in the browser; only choosing an item reaches the app, as doClick.

Dialogs

No patching is needed: JOptionPane and JFileChooser are ordinary Swing components, and the bridge drives them through their public APIs.

The app callsThe browser sees
JOptionPane.show*DialogA modal window: the message, an icon from the message type, an input field for showInputDialog, the pane's own buttons, and its default button
JFileChooser.showOpenDialogA file picker (accept from a FileNameExtensionFilter, multi-selection honoured). The browser uploads, and the selection is set to the uploaded copy and approved.
JFileChooser.showSaveDialogA file-name prompt (the filter's extension is added if missing). The app writes to a temporary path, and the browser downloads the file once it has stopped changing.
Any modal JDialogA modal window. Escape sends WINDOW_CLOSING, as a window manager would.

An exception thrown by an app listener appears as an error notification in the browser.

System.exit

With -Djava.security.manager=allow, the bridge installs a security manager that allows everything except a real System.exit. An app's File › Exit then ends that browser session (or, in shared mode, the app) instead of stopping the bridge. Permission probes such as setDefaultCloseOperation(EXIT_ON_CLOSE) still succeed.

JDK 24 and later removed the Security Manager permanently (JEP 486). There the bridge logs a warning and cannot trap System.exit, so an app that calls it would stop the bridge. For such apps, run the bridge on JDK 8–23.

Theme

The look and feel's colours and font (from UIManager) and the main window's background become XUL-J theme tokens, so a Metal, Nimbus or FlatLaf app keeps its character; a dark app stays dark in dark mode. Labels with a colour of their own get a role: red danger, orange warning, green success, grey muted.

The browser applies these as design tokens, never as CSS, and the viewer stays in charge: in high-contrast mode producer colours are ignored, in dark mode only a dark palette is used, and colour pairs that would fall below WCAG contrast are dropped.

Selection & context menus

JTable, JList and JTree report their selection, and browser selections go through the selection model, so listeners fire. Double-click or Enter dispatches a real two-click MouseEvent, which is how Swing apps detect it.

A component's setComponentPopupMenu becomes a context menu (also when the component sits in a scroll pane). Right-click selects the row and opens it; the bridge sets the invoker and runs the app's PopupMenuListeners, so items enable and relabel as on the desktop. Menus an app shows by hand from a MouseListener are not detected yet.

AI agents (MCP)

The XUL-J MCP server lets an AI agent operate the app through the same semantic tree the browser renders: labelled elements, explicit commands, enabled state, tables and dialogs. No screenshots, no pixel guessing. Each agent connection is its own session, with its own instance of the app.

claude mcp add inventory -- node ../xul-j/mcp/server.js --url http://127.0.0.1:8093
ToolUse
get_ui, list_commandsRead the screen as an outline with ids, values, commands, tables and dialogs
set_value, do_commandFill fields, then press buttons or choose menu items
read_table, select_rows, activate_rowWork with grids and lists
open_context_menuOpen a right-click menu; the app enables its items
upload_file, download_fileAnswer open and save dialogs
wait_forWait for something slow to finish

Every action answers with what changed, so the agent rarely needs to re-read the screen. test/mcp-e2e.js has an agent answer an error dialog, add an item, duplicate it from the right-click menu and change a setting through an input dialog.

HTTP endpoints

RequestPurpose
GET /The browser client (embedded in the jar)
GET /stream?session=<id>Server-sent events. Creates the session on first use (per-session mode), and resumes from Last-Event-ID or ?from=.
POST /intent?session=<id>{"op":"do","command":…}, {"op":"input","id":…,"value":…}, {"op":"select","id":…,"rows":[…]}, {"op":"activate","id":…,"row":…} or {"op":"contextmenu","id":…,"target":…}. Returns 202, or 400, 404 or 409.
POST /upload?session=<id>&id=<picker>A file for an open dialog, with the name in X-Filename. Returns 202, 404 or 413 (over 100 MB).
GET /download/<token>A file the app saved, sent as an attachment. The token is 128 random bits.

Security

Limitations

Troubleshooting

The Docker container starts but serves nothing, and logs nothing.
Run it with --init. As PID 1, xvfb-run never sees Xvfb's ready signal and waits forever.
HeadlessException
There is no display. Run under xvfb-run, or set DISPLAY.
“cannot trap System.exit” at startup.
Add -Djava.security.manager=allow (Java 18–23), or see System.exit for JDK 24 and later.
The stream answers with HTTP 500 and “cannot create …”.
The window's constructor threw. The cause is in the bridge's log; often the app expects setup done in its main(), in which case use shared mode or embedding.

Development

src/org/xulj/bridge/Renderer.java    windows → virtual XUL-J tree (layouts, mapping, menus, dialogs)
src/org/xulj/bridge/Reconciler.java  frame diffing → minimal ops
src/org/xulj/bridge/Host.java        sessions, EDT tick, HTTP/SSE, intents, uploads, downloads, exit trap
src/org/xulj/bridge/Launcher.java    command line
src/org/xulj/bridge/Json.java        dependency-free JSON
demo/src/legacy/InventoryFrame.java  a 2000s-style Swing demo with menus, table, timers and dialogs
test/swing-e2e.js                    end-to-end test against a running bridge (needs ../xul-j)
test/mcp-e2e.js                      an agent operating the demo through the XUL-J MCP server
node test/swing-e2e.js http://127.0.0.1:8093
node test/mcp-e2e.js http://127.0.0.1:8093

Source and issues: github.com/xul-j/java-bridge. MIT licensed.