This page documents ffwebapps-tray: a StatusNotifierItem that shows each app’s icon, unread badge, and menu, and is built around one deliberate constraint — it can spawn nothing and holds no authoritative state of its own.
ffwebapps-tray (src/bin/ffwebapps-tray.rs) is a StatusNotifierItem built on the ksni crate. Its entire job is to draw an icon with an unread badge and a menu, and to translate clicks into socket verbs. The defining property is what it cannot do:
The tray spawns nothing. There is no
std::process::Commandanywhere in the file. The only process-control primitive is a singlelibc::killsyscall used by Quit.
This is a direct response to a class of bugs where a tray “helpfully” relaunched its app and produced orphans or zombie windows. Because the tray can never launch anything, it can never relaunch, duplicate, or strand a process. Every user action — show, hide, reload, mute, quit — is a pure socket write; the runtime does the actual work.
The tray is a thin remote of the runtime. The runtime is the single source of truth for window visibility and every toggle; the tray merely reflects what the runtime pushes and forwards what the user clicks.
Exactly one tray may exist per app. The guard is an advisory file lock (acquire_singleton, ffwebapps-tray.rs:92-98): the tray opens $XDG_RUNTIME_DIR/ffwebapps-tray-<id>.lock and takes flock(fd, LOCK_EX | LOCK_NB). If the lock is already held, a tray already owns this app and the new process exits immediately. The lock file is held for the whole process lifetime.
flock was chosen because the kernel releases the lock automatically when the process exits or dies — there are no stale lock files to clean up and no PID-reuse hazard. It is the same “let the OS track liveness” philosophy as the runtime’s socket.
The tray derives the socket path purely from the ULID it was given — $XDG_RUNTIME_DIR/ffwebapps-<id>.sock — no path is ever passed in. Because the tray is usually spawned right after Firefox, before Firefox has bound the socket, connect() retries (ffwebapps-tray.rs:104-113):
hello v1 tray\n and start the reader.The hello v1 tray handshake is what tells the runtime to treat this connection as a tray — which is what enables close-to-tray (the runtime only intercepts a window close while a tray client is connected; see IPC & the Runtime-Owned Window).
A dedicated reader thread consumes the socket line-by-line (ffwebapps-tray.rs:329-374) and updates a shared State behind an RwLock. It parses three message kinds:
hello v1 <pid> → records the runtime PID (needed for the Quit fallback).unread <n> → updates the badge, redrawing only when the count changed.state hidden=… muted=… dnd=… suspend=… autostart=… → updates the five toggle flags and refreshes the menu.The unread badge is rendered as a quiet mail-unread overlay icon (no pulsing) and surfaced again in the tooltip as “N unread”. The tray holds no state of its own and never asks the runtime for anything — it only reacts to pushes. When the runtime closes the socket, BufReader::lines() ends, and the thread logs “runtime closed the connection → tray exiting” and calls std::process::exit(0). The flock then releases automatically. This is what binds the tray’s lifetime to its app’s.
The menu (ffwebapps-tray.rs:210-283) is rebuilt from the latest state snapshot on each draw, so labels and checkmarks always reflect the runtime:
| Item | Sends | Notes |
|---|---|---|
| Show / Hide | toggle |
Label flips on the hidden flag |
| Reload | reload |
|
| Mute | mute-toggle |
Checkmark = muted |
| Do not disturb | dnd-toggle |
Checkmark = dnd |
| Suspend when hidden | suspend-toggle |
Checkmark = suspend |
| Start on login | autostart-toggle |
Checkmark = autostart |
| Copy URL | copy-url |
|
| Open page in browser | open-browser |
|
| Quit | (see §6) |
Note that Show/Hide always sends toggle, never show/hide directly — only the runtime knows the true visibility, so it makes the decision. The checkable items are interesting because the runtime persists most of them as Firefox prefs (ffwebapps.muted, dom.webnotifications.enabled, ffwebapps.suspendWhenHidden), and “Start on login” is backed by the actual existence of the autostart .desktop file — so the checkmark can never lie about reality.
Quit is the one place the tray reaches past the socket, and it does so with a syscall, not a subprocess (quit_app, ffwebapps-tray.rs:159-169):
hello v1 <pid> line).quit over the socket.libc::kill(pid, SIGKILL) as a guaranteed teardown.std::process::exit(0).The fallback exists so Quit always wins: even if the runtime hangs during shutdown, the app cannot survive as a trayless, unreachable window. Using a direct kill syscall (rather than spawning pkill) keeps the “tray spawns nothing” invariant intact — and avoids the classic pkill -f ffwebapps-tray footgun of matching one’s own shell.
ksni’s service.run() returns when the StatusNotifier host (e.g. plasmashell) goes away — which happens on a Plasma restart. The tray wraps it in a loop (ffwebapps-tray.rs:380-389): on return it waits 500 ms and re-creates the service, so the icon reappears once the new host is up. The live Handle is swapped into a shared slot so the reader thread always pushes badge/menu refreshes to the current generation.
The tray is spawned only by spawn_tray (console/site.rs:45-68), from two places: after a normal launch, and on the already-running focus path (so a relaunch that just focuses an existing window still guarantees a tray exists). It is invoked as:
ffwebapps-tray --id <ULID> --name <App Name> --icon FFPWA-<ULID>
The binary is discovered next to the running ffwebapps (via current_exe()), falling back to dirs.executables. That ordering is deliberate: the .desktop launcher does not set FFPWA_EXECUTABLES, so a menu/taskbar launch would otherwise resolve the tray to the wrong directory and silently fail to start. --wmclass and --exec are still accepted but ignored, for compatibility with older launchers.