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.
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]
| Option | Meaning |
|---|---|
--jar | The application jar, loaded in its own class loader (optional if the app is already on the classpath). |
--frame | Per-session mode: a java.awt.Window subclass with a no-argument constructor, created once per browser session. |
--main | Shared mode: the class whose main() is run once. It defaults to the jar's Main-Class. |
--port, --host | Where 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
| Mode | Behaviour | Good 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
- 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_CLOSEframes are switched toDISPOSE_ON_CLOSE. - 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. - Intents. An intent is validated with
invokeAndWait(unknown target 404, disabled 409), then run withinvokeLater, so a modal dialog never blocks the HTTP server. Buttons are pressed withdoClick, and check boxes and radio buttons are clicked rather than set, soActionListeners run as they would for a user. Text goes throughsetText, combos throughsetSelectedIndex, and spinners throughsetValuewith the model's own number type. - Ids. Ids come from the app's own field names (
private JButton addButton→addButton), then component names, then button labels (Import CSV…→importCsv). Commands arecmd_<id>.
Layout
| Swing | XUL-J |
|---|---|
BorderLayout | vbox: north, then an hbox of west / center (flex) / east, then south |
FlowLayout | hbox, with spacers for RIGHT and CENTER alignment |
BoxLayout | hbox or vbox; glue becomes a spacer, and scroll panes, tables, lists and text areas grow |
GridLayout / CardLayout | one hbox per row with equal cells / stacked cards (inactive ones hidden) |
GridBagLayout | Rows from the real bounds. weightx + fill becomes flex, weighty + fill makes the row grow, and an EAST anchor right-aligns. |
GroupLayout, null layout, others | Rows 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, JScrollPane | Two panes sized by the divider; tabbox; its view |
Panel with a TitledBorder | groupbox |
Component mapping
| Swing | XUL-J |
|---|---|
| JFrame, JDialog | window (modal for modal dialogs; Escape closes them) |
| JButton | button + command (the root pane's default button is primary; the mnemonic becomes Alt+key) |
| JTextField, JFormattedTextField, JPasswordField, JSpinner | textbox (password) |
| JTextArea, JEditorPane | textbox multiline |
| JCheckBox, JRadioButton, JToggleButton | checkbox |
| JComboBox | menulist |
| JTable, JList, JTree | tree with a row source (JTree rows indented by depth) |
| JProgressBar, JSlider | progressmeter |
| JToolBar | toolbar |
| JLabel | label (HTML labels reduced to text) |
| anything else | a muted [ClassName] placeholder |
Menus
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 calls | The browser sees |
|---|---|
JOptionPane.show*Dialog | A 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.showOpenDialog | A file picker (accept from a FileNameExtensionFilter, multi-selection honoured). The browser uploads, and the selection is set to the uploaded copy and approved. |
JFileChooser.showSaveDialog | A 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 JDialog | A 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.
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
| Tool | Use |
|---|---|
get_ui, list_commands | Read the screen as an outline with ids, values, commands, tables and dialogs |
set_value, do_command | Fill fields, then press buttons or choose menu items |
read_table, select_rows, activate_row | Work with grids and lists |
open_context_menu | Open a right-click menu; the app enables its items |
upload_file, download_file | Answer open and save dialogs |
wait_for | Wait 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
| Request | Purpose |
|---|---|
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
- There is no authentication. Every per-session window is a live instance of your application on the host. Bind to
localhost, or put the bridge behind an authenticating reverse proxy. - In shared mode every viewer sees, and can change, the same app, including password fields.
- There is no session limit. Idle sessions close after
sessionTtlMs. - The browser never receives code. Uploads only reach file choosers, and downloads only serve files saved through a save dialog.
Limitations
- Custom-painted components, images and charts don't render.
- Cell editing isn't mapped yet, and popup menus shown by hand (
popup.show) aren't detected. - AWT's native
FileDialogisn't intercepted (Swing apps rarely use it). - Class loading assumes a plain jar; apps that need a custom launcher or a module path need embedding.
Troubleshooting
- The Docker container starts but serves nothing, and logs nothing.
- Run it with
--init. As PID 1,xvfb-runnever sees Xvfb's ready signal and waits forever. HeadlessException- There is no display. Run under
xvfb-run, or setDISPLAY. - “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.