XUL-J bridge · .NET WinForms
net-bridge
Serve an existing, unmodified WinForms application to browsers. Each browser session gets its own instance of the app's main form, kept off-screen. The bridge streams the live control tree as XUL-J operations and turns clicks and edits in the browser into real events on the real controls.
Quick start
Linux, with Docker (Mono + Xvfb)
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/net-bridge && cd net-bridge
docker build -t xulj-mono docker
./build.sh # → out/xulj-host.exe, out/0Harmony.dll, out/LegacyOrders.exe
docker run -d --init --name xulj-bridge -p 8092:8092 -v "$PWD/out":/app:ro \
xulj-mono xvfb-run -a mono /app/xulj-host.exe /app/LegacyOrders.exe --port 8092
Open http://localhost:8092/. Every browser tab gets its own copy of the demo app.
Windows
Compile the sources with a C# 7.3 compiler: Roslyn's csc from Visual Studio or the
Build Tools. The csc.exe under Windows\Microsoft.NET\Framework only
understands C# 5 and will not work. Fetch 0Harmony.dll (Lib.Harmony 2.3.3,
lib/net472) into lib\ first, or run build.sh once on any
machine with Docker.
csc -langversion:7.3 -target:exe -out:xulj-host.exe ^
-r:System.Windows.Forms.dll -r:System.Drawing.dll -r:System.Net.dll -r:lib\0Harmony.dll ^
-resource:..\xul-j\public\index.html,public/index.html ^
-resource:..\xul-j\public\xulj.js,public/xulj.js ^
-resource:..\xul-j\public\xul.css,public/xul.css ^
src\*.cs
xulj-host.exe C:\Apps\Orders\Orders.exe --host localhost --port 8092
On a desktop no virtual display is needed. Off-screen forms are moved to (−32000, −32000).
HttpListener needs a URL reservation for any host other than
localhost: netsh http add urlacl url=http://*:8092/ user=Everyone.
Command line
xulj-host.exe App.exe [--form Namespace.MainForm] [--port 8092] [--host *]
| Option | Meaning |
|---|---|
App.exe | The WinForms assembly to host. Its own DLLs are resolved from its folder, which also becomes the working directory. |
--form | The form to instantiate per session (full or short type name). Without it: MainForm, then Form1, then the only form with a parameterless constructor. If none fits, the candidates are listed. |
--port | Listening port (default 8092). |
--host | Listening host for HttpListener (default *). Use localhost unless the machine is behind your own access control. |
The app's own Main() is not run, so setup done there (configuration, login, data connections) is skipped. If the app needs it, use embedding instead.
Embedding
If you can change the app, replace its Application.Run call and keep everything Main does:
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Config.Load(); // your startup code still runs
XulJBridge.Run(() => new MainForm(), new BridgeOptions
{
Port = 8092,
Host = "localhost",
TickMs = 50, // how often the UI is diffed
SessionTtl = TimeSpan.FromMinutes(10), // idle sessions are closed after this
Log = Console.Out,
});
}
How it works
- Sessions. When a browser opens
/stream?session=…, the bridge creates the form on the UI thread, moves it off-screen and shows it, soLoadfires and the app's timers start. Forms the app opens later (dialogs, more windows) belong to the session that acted last. - Rendering. Every
TickMsthe bridge walks the session's forms and builds a virtual XUL-J tree, inferring layout as described below. - Diffing. The new tree is compared with the previous one, and only the differences are sent, in an order the client can apply: command changes, removals, attribute updates, new elements (parents first), then row data. Grid rows are appended when the old rows are a prefix of the new ones.
- Streaming. Operations are numbered and logged per session. A client that reconnects with
Last-Event-IDreceives only what it missed. Downloads and notifications are transient and never replayed. - Intents. A browser click arrives as
{"op":"do","command":"cmd_addButton"}. It is checked synchronously on the UI thread (unknown target 404, disabled 409), then run withBeginInvoke, so an app that opens a modal dialog never blocks the HTTP server. Clicks call the control's ownOnClick(orToolStripItem.PerformClick), soClickhandlers andButton.DialogResultbehave as usual. Edits setText,Checked,SelectedIndexorValue, which raise the app's change events.
Layout inference
WinForms positions controls absolutely; XUL-J lays them out with flexible boxes. The bridge converts as follows:
| WinForms | XUL-J |
|---|---|
Dock Top / Bottom / Left / Right / Fill | Nested vbox and hbox, processed in WinForms' order (the last control in Controls docks first). Fill gets flex: 1. |
| Absolutely placed controls | Grouped into rows by vertical overlap and ordered by Left. Each control's width runs up to the next control's Left, which keeps the designer's label column aligned. |
Anchor Left | Right | flex: 1 (stretches) |
Anchor Right only | A spacer before it (right-aligned) |
Anchor Top | Bottom | Its row grows (flex, align: stretch) |
AutoSize controls | Keep their natural size, because their text can change |
FlowLayoutPanel / TableLayoutPanel | hbox or vbox / one hbox per table row |
SplitContainer | Two panels, the first sized by SplitterDistance |
Element ids come from the control's Name, then from a strip item's text
(Export CSV… → exportCsv), then from its type. Commands are
cmd_<id>. &Add becomes Alt+A, and CancelButton
becomes Escape.
Control mapping
| WinForms | XUL-J |
|---|---|
| Form | window (modal when shown with ShowDialog) |
| Button | button + command (AcceptButton is primary) |
| TextBox, MaskedTextBox, RichTextBox, NumericUpDown | textbox (password, multiline) |
| CheckBox, RadioButton | checkbox (radio exclusivity stays WinForms' own) |
| ComboBox, ToolStripComboBox | menulist |
| DataGridView, ListView, ListBox | tree with a row source (formatted cell values) |
| ProgressBar, TrackBar, ToolStripProgressBar | progressmeter |
| TabControl / TabPage | tabbox / tabpanel |
| GroupBox, Panel, UserControl, SplitContainer, FlowLayoutPanel, TableLayoutPanel | groupbox / boxes |
| ToolStrip, StatusStrip | toolbar, statusbar (Spring labels flex) |
| Label, DateTimePicker | label, read-only textbox |
| anything else | a muted [TypeName] placeholder |
Menus
MenuStrip becomes a menubar. Every ToolStripMenuItem with
drop-down items becomes a menu (submenus included), separators become
menuseparator, and leaf items become menuitem with a command.
&File opens with Alt+F, ShortcutKeys are shown next to the item and
bound, and Checked / CheckOnClick items show a check mark.
ToolStripDropDownButton and ToolStripSplitButton become drop-down menus
in the toolbar.
Menus open and close in the browser with no round trip. They support hovering across the bar,
arrow keys, Home and End, and Escape. Only choosing an item reaches the app, as
PerformClick.
Dialogs
On Windows, MessageBox and the common dialogs are native windows that the bridge
could neither see nor drive. At startup it patches them with
Harmony (MIT). On the bridge's UI thread they
then show ordinary managed forms, rendered as modal XUL-J windows. Calls from other threads run
the original implementation.
| The app calls | The browser sees |
|---|---|
MessageBox.Show(…), all overloads | A modal window with the message, icon and the same buttons. The default button responds to Enter and the cancel button to Escape. The app gets the matching DialogResult. |
OpenFileDialog | A file picker (accept from the first Filter, Multiselect honoured). The browser uploads, and FileName / FileNames point at the uploaded copies, which keep their original names. |
SaveFileDialog | A file-name prompt. The app writes to a temporary path; once the file has stopped changing, the browser downloads it. |
Microsoft.VisualBasic.Interaction.InputBox | A prompt with a text box (an empty string on Cancel, as in VB) |
| Print, Color, Font, FolderBrowser dialogs | Cancelled, with a notification |
The app's own Form.ShowDialog() | A modal window; the owner is disabled meanwhile |
An exception thrown by an app event handler appears as an error notification in the browser.
Theme
The form's BackColor, ForeColor and Font, and the system highlight colour, become XUL-J theme tokens; density follows the font size (8.25 pt is compact). A dark app stays dark in dark mode. Labels painted in a colour of their own get a role instead of a raw colour: a Firebrick error label becomes danger, an orange one 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
DataGridView (full-row or cell selection), ListBox and ListView report their selection, and selections made in the browser are applied as a user's would be, so SelectionChanged fires. Double-click or Enter raises CellDoubleClick, ItemActivate or DoubleClick.
A control's ContextMenuStrip becomes a context menu. Right-click selects the row and opens it at the pointer; the bridge sets SourceControl and runs the app's Opening handler, so items enable and relabel as on the desktop ("Delete 2 orders…").
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 legacy-orders -- node ../xul-j/mcp/server.js --url http://127.0.0.1:8092
| 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 fix a validation error, duplicate a row from the right-click menu, answer a confirmation, and export and re-import a CSV.
HTTP endpoints
| Request | Purpose |
|---|---|
GET / | The browser client (embedded in xulj-host.exe) |
GET /stream?session=<id> | Server-sent events. Creates the session (and its form) on first use, 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 (malformed), 404 (unknown target) or 409 (disabled). |
POST /upload?session=<id>&id=<picker> | A file for a file picker, with the name in X-Filename. Returns 202, 404 (not a file picker) 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. Anyone who reaches the port can start sessions, and every session is a live instance of your application running with the bridge's rights. Bind to
localhost, or put the bridge behind a reverse proxy that authenticates. - There is no session limit. Idle sessions are closed after
SessionTtl. - The browser never receives code. Uploads only reach file pickers, and downloads only serve files the app saved through a save dialog.
Limitations
- Owner-drawn controls, custom painting and images don't render.
- Cell editing isn't mapped yet; tables are read-only in the browser.
- Dialogs opened through P/Invoke (
user32!MessageBox,GetOpenFileName) aren't intercepted. - Heavily overlapping absolute layouts come out as rows.
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. HttpListenerException: Access is deniedon Windows.- Use
--host localhost, or add a URL reservation withnetsh http add urlacl. - “dialog patches unavailable” in the log.
0Harmony.dllis missing from next toxulj-host.exe. Without it, native dialogs stay invisible to the browser.- A control shows as
[SomeControl]. - It has no mapping yet. Please open an issue with the control type.
Development
src/Renderer.cs control tree → virtual XUL-J tree (layout inference, mapping, menus)
src/Reconciler.cs frame diffing → minimal ops
src/Bridge.cs sessions, UI thread, HttpListener, SSE, intents, uploads, downloads
src/Dialogs.cs Harmony patches and browser-friendly dialog forms
src/Launcher.cs xulj-host.exe command line
src/Json.cs dependency-free JSON
demo/LegacyOrders.cs a designer-style demo app with menus, grid, timers and dialogs
test/bridge-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/bridge-e2e.js http://127.0.0.1:8092
node test/mcp-e2e.js http://127.0.0.1:8092
Source and issues: github.com/xul-j/net-bridge. MIT licensed.