ffwebapps — IPC & the Runtime-Owned Window

This page documents the single Unix socket that ties a running web app to its tray and launcher: the wire protocol, the runtime-owned window model, close-to-tray, and the hard-won KWin hide/show mechanism.

Table of Contents

  1. One socket, one invariant
  2. The wire protocol
  3. The server: nsIServerSocket in the runtime
  4. The clients
  5. The singleton launcher
  6. Close-to-tray
  7. Hide and show
  8. Hard-won gotchas

1. One socket, one invariant

A running web app exposes exactly one IPC boundary: a Unix-domain socket at $XDG_RUNTIME_DIR/ffwebapps-<ULID>.sock (falling back to /tmp when XDG_RUNTIME_DIR is unset). The socket is served by the Firefox runtime itself — the privileged _autoconfig.cfg binds it — and consumed by thin clients: the tray, the launcher, and the GTK GUI.

The architecture rests on a single invariant:

The runtime is alive if and only if the socket accepts a connection.

There are no pidfiles and no sentinel files. A leftover socket file from a crash refuses connections, so a failed connect is the liveness check. This one fact powers three separate behaviours — the singleton launcher (don’t open a duplicate), the tray’s lifetime (exit on EOF), and the GUI’s running/hidden status dot — without any of them tracking a PID.

the one socket — runtime is server, everyone else is a client Firefox runtime (_autoconfig.cfg) nsIServerSocket.initWithFilename(0o600) owns: window, hide/show, unread, toggles single source of truth $XDG_RUNTIME_DIR/ffwebapps-<ULID>.sock newline-delimited text; versioned "v1" serves ffwebapps-tray hello v1 tray → toggle / reload / quit / … ← unread / state EOF ⇒ tray exits draws icon + badge + menu launcher (site launch) connect succeeds? yes → hello v1 launcher; show → focus, don't duplicate no → spawn the runtime ffwebapps-gtk hello v1 launcher live-control panel + per-row running dot monitors, sends verbs

2. The wire protocol

The protocol is newline-delimited UTF-8 text, versioned v1 in every handshake. Each connection opens with the client identifying itself; the runtime immediately replies with a three-line state dump, then pushes updates on change.

Client → runtime

Message Meaning
hello v1 tray Identify as the tray (enables close-to-tray; see §6)
hello v1 launcher Identify as a launcher / monitor (no special handling)
show / hide / toggle Map / unmap / flip the window
quit Force-quit the runtime (Services.startup.quit(eForceQuit))
reload Reload the current page
mute-toggle Flip audio mute (persisted in ffwebapps.muted)
dnd-toggle Flip do-not-disturb (dom.webnotifications.enabled)
suspend-toggle Flip “suspend when hidden” (ffwebapps.suspendWhenHidden)
autostart-toggle Toggle the launch-on-login autostart entry
copy-url Copy the current page URL to the clipboard
open-browser Open the current page in the default browser

Runtime → client

Message When
hello v1 <pid> Once, on connect — the runtime’s process ID
unread <n> On connect and whenever the unread count changes
state hidden=<0|1> muted=<0|1> dnd=<0|1> suspend=<0|1> autostart=<0|1> On connect and after any toggle changes

The unread count is scraped from the window title: a repeating 1-second timer matches a leading (N) in document.title and broadcasts unread N when it changes (_autoconfig.cfg:932-952). Because the page keeps running even while the window is hidden, the badge stays accurate in the tray.

A subtle but important detail: the tray never sends show or hide directly — it always sends toggle and lets the runtime decide, because the runtime is the only component that knows the true hidden state (see §8).

3. The server: nsIServerSocket in the runtime

The socket lives entirely inside _autoconfig.cfg (377-956). At startup the cfg derives the app ID from MOZ_APP_REMOTINGNAME (ffwebapps-<ulid><ulid>), builds the socket path, and binds:


srv.initWithFilename(f, 0o600, -1);   // 0600 = owner-only
srv.asyncListen(_listener);

A few server details matter:

The runtime holds the authoritative state for every client; clients never poll and never hold state of their own — they reflect unread / state pushes and forward user intent as verbs.

4. The clients

The tray (ffwebapps-tray.rs) connects, sends hello v1 tray, and runs a reader thread over BufReader::lines(). It mirrors the runtime’s state/unread into a shared struct and redraws the icon and menu. When the runtime closes the socket the iterator ends, and the tray calls std::process::exit(0) — its lifetime is bound to the app’s. It is documented in detail in System Tray.

The launcher (console/site.rs:29-41) is fire-and-forget: it connects, writes hello v1 launcher plus optionally show, and never reads a reply. See §5.

The GTK GUI (ffwebapps-gtk/ipc.rs) identifies as a launcher (not a tray), so it can monitor state and send verbs without triggering close-to-tray semantics. Its reader runs on a std::thread that pushes LiveEvents into an async-channel, which a glib::spawn_future_local drains onto the GTK main loop. An echo guard prevents an inbound state update from bouncing a verb back out. See GTK Management GUI.

5. The singleton launcher

Single-instance behaviour falls straight out of the liveness invariant. When you launch an app — from the menu, the dock, or site launch <ULID> — the console first probes the socket (runtime_show, console/site.rs:29-41):


connect $XDG_RUNTIME_DIR/ffwebapps-<ULID>.sock
  ├─ fails   → app not running → write registry + prefs, spawn the runtime
  └─ succeeds → app already running → send "show", spawn the tray if needed, return

If there is no explicit target URL or protocol and the connect succeeds, the launcher sends show to focus the existing window and exits without spawning a second Firefox (SiteLaunchCommand::run, console/site.rs:93-101). This avoids the “open in another window / use here” prompt that a duplicate taskbar-tab window would trigger for a single-page app. A --hidden relaunch sends only the handshake, so it doesn’t un-hide a backgrounded app.

The other half of single-instance is the unique MOZ_APP_REMOTINGNAME=ffwebapps-<ulid> set at launch (components/site.rs:299): without it the app would share Firefox’s default firefox remoting name and could both intercept external-link launches meant for the user’s browser and let a relaunch attach to the wrong instance.

6. Close-to-tray

The window’s X button is intercepted and turned into a hide — but only while a tray client is connected to bring it back. Otherwise the close proceeds for real, so the X can never trap the window with no way to restore it.

Taskbar-tab windows take an early return in Firefox’s warnAboutClosingWindow and never fire browser-lastwindow-close-requested, so the cfg overrides the actual close path instead (_autoconfig.cfg:875-927). It hooks all three routes a close can travel — the compositor close event, WindowIsClosing(), and window.close() — and each checks one predicate:


const intercept = () => isTab() && !_quitting && _trayConnected();

So a close becomes a hide only when the window is a taskbar-tab, the runtime is not already quitting, and a tray is connected. quit sets _quitting = true first, so a real quit is never vetoed. This is also where a --hidden autostart launch is honoured: once the window maps, a short timer hides it to the tray.

7. Hide and show

Hiding looks trivial but is the most-debugged part of the system. The naive approach — unmap the toplevel via nsIBaseWindow.visibility — works everywhere but loses the window’s position on KWin Wayland: a remapped toplevel is a new window to the compositor, and KWin re-places it. So the runtime uses two mechanisms and picks per-compositor (_autoconfig.cfg:474-580):

Compositor Mechanism Effect
KWin (KDE) Move the window off-screen by a fixed -50000 x-offset with skipTaskbar/skipSwitcher/skipPager set The surface stays mapped, so geometry, position, and rendering are preserved exactly
Other Unmap via nsIBaseWindow.visibility Hides reliably everywhere; re-show placement is left to the compositor

On KWin the cfg writes a tiny KWin script to $XDG_RUNTIME_DIR/ffwebapps-<id>.kwin.js, matched to this app’s window by the runtime’s PID and a webapp resource class, and runs it over D-Bus (qdbus6/qdbus). The off-screen moves are gated on the window’s actual position (if(w.frameGeometry.x>-10000)…), making them idempotent: a stale hidden-state flag can never push a visible window off-screen or pull a hidden one twice. On KDE an install-time kwinrulesrc “Remember position” rule (positionrule=4) further helps KWin restore placement — see Desktop Integration.

An optional “suspend when hidden” mode gives the page background-tab semantics while hidden — docShellIsActive = false and renderLayers = false (_autoconfig.cfg:602-612) — throttling timers and stopping layer rendering to save CPU/GPU, without killing WebSockets, so chat stays connected. It is fully restored on show.

8. Hard-won gotchas

The comments in _autoconfig.cfg and HANDOFF.md record several traps that shaped the current design:

  1. nsIBaseWindow.visibility cannot be read back. AppWindow::GetVisibility hardcodes true (Mozilla bug 306245). The runtime therefore tracks _hiddenFlag itself; trusting the getter would make toggle always choose “hide”.
  2. Unmap/remap loses position on KWin Wayland. Empirically 500,300 → remap → 1165,811. Hence the off-screen-move mechanism that keeps the surface mapped.
  3. KWin runs loaded scripts asynchronously. The Script.run D-Bus call returns before KWin executes the body, so the old “load && run && unload” sequence raced — the trailing unload tore the script down before it ran, and a single toggle did nothing. The fix uses a stable per-app plugin name, unloads any leftover first (forcing a fresh read), loads, runs, and leaves it loaded for the next call to clean up. Under XWayland the unmap fallback masked this; under native Wayland the fallback is a no-op, so the bug surfaced as dead hide/show (commit a908e16).
  4. Mixed binary versions cause “app relaunches after quit” bugs. Always install one version and make sure .desktop Exec lines point at the installed binary, not target/debug.

These are the reason the runtime owns its own window: every workaround above needs to read or change Firefox-internal state, which only privileged in-process JS can do reliably.