Desktop integration
September 6, 2026 · View on GitHub
What an app has to tell the desktop about itself, beyond drawing. Today that is startup notification, which is on by default and has nothing to call; the launcher's view of the app — a badge on its icon, a bounce for attention, the Dock's menu; what a password field has to do to be reachable by the desktop's password managers; and the one switch that turns off everything here that talks over D-Bus.
The menu bar is the other half of this and has a page of its own —
globalmenu.md — because on a desktop that shows application
menus in its panel, MenuBar hands the menu over and stops drawing. Same
default shape as startup notification: on with no configuration, and one
switch to turn it off.
Everything on this page is the app talking outwards. The one thing that
comes the other way — the desktop handing the app a myapp://… link to open,
and the single-instance question that goes with it — is
uri-schemes.md. It shares the launch timestamp described
below: ctx.timestamp there is the same _TIME suffix launchTimestamp()
parses here, and for the same reason.
Startup notification
When a launcher spawns an app it opens a startup sequence — that is what
the busy cursor is — and closes it when the app says it is up. An app that
never says so leaves the sequence running until the desktop gives up on it:
mutter's STARTUP_TIMEOUT_MS is 15 seconds, long after the window is on
screen and being clicked. The mutter source is blunt about what that looks
like, in a comment on the constant itself: people assume the launch failed
and start it again.
Two other things ride on the same handshake:
- Focus.
_NET_WM_USER_TIMEis the evidence focus-stealing prevention weighs when deciding whether a new window may come to the front. With none, a strict desktop opens the window behind whatever the user was doing — correctly, from its point of view, since nothing said a user asked for this. - Placement. The sequence records which workspace the launch happened on. Without one the window goes wherever new windows go.
react-x11 does all of it with no configuration:
const root = await createRoot(); // that is the whole of it
DESKTOP_STARTUP_ID is read from the environment and removed from it.
That is deliberate and it matters: the variable names one launch, so a child
process that inherits it would claim a sequence that is not its own and end
it early — the parent's cursor stops when the child starts. Every toolkit
that gets this wrong produces that same bug.
With no id in the environment — a terminal, CI, XQuartz — nothing is set and nothing is sent.
When is an app "started"?
The default is the first frame that actually painted.
GTK ends the sequence when the toplevel maps, and copying that here would be
subtly wrong. This renderer does not paint on map: invalidate() schedules
through the frame clock and the drawing lands in flush() a frame later. So
a mapped window is an empty rectangle, and stopping the busy cursor there is
compliant and dishonest — it says "ready" over a blank window.
A suspense fallback counts as painted, and should. If the first frame is a spinner because the tree is waiting on data, that is exactly the right moment to stop the system's spinner: the app is up and is telling the user what it is doing. There is no signal for "finished loading", and guessing at one is how this ends back at fifteen seconds.
completeOn is there for apps the default does not suit:
await createRoot({ startupNotification: { completeOn: 'map' } });
completeOn | |
|---|---|
'paint' | the first frame that drew (default) |
'map' | the first toplevel mapping — earlier, and what GTK does |
'manual' | nothing automatic; call notifyStartupComplete() |
'map' suits an app whose first frame is expensive enough that it would
rather the cursor stopped before it. 'manual' suits one that is not up
until it says so — restoring a session behind a splash, say:
import { notifyStartupComplete } from 'react-x11';
await createRoot({ startupNotification: { completeOn: 'manual' } });
await restoreSession();
notifyStartupComplete(); // idempotent, and a no-op if there is no sequence
A backstop ends the sequence regardless, ten seconds after the window maps, whichever mode is in force. An app that never paints, or that forgets to call, cannot leave the cursor spinning — which would be this feature reproducing the bug it exists to fix. Ten is chosen to beat mutter's fifteen by a margin while being far longer than any honest first frame.
Turning it off, and supplying an id
await createRoot({ startupNotification: false });
await createRoot({ startupNotification: 'launcher/app/1-0_TIME9876' });
false is for an app that runs its own sequence, or an embedder that owns
the toplevel. Note that opting out leaves DESKTOP_STARTUP_ID in the
environment — if you are managing the sequence yourself, the id is yours to
read and yours to clear.
A string supplies the id for a launch where it did not arrive in the
environment. A D-Bus-activated app gets it in platform_data instead.
launchTimestamp()
import { launchTimestamp } from 'react-x11';
const when = launchTimestamp(); // number | null
The X server timestamp of the user action that launched the app, parsed from
the id's _TIME suffix. null is a real answer, not a failure: an app
started from a shell has no launch timestamp and never will.
It is the "when" that any later request to come forward is weighed against.
Do not substitute 0 for a missing one — EWMH gives zero its own meaning,
"do not focus this window when it maps".
On macOS
quartz-wm implements none of this, so on XQuartz the messages go to a root
window nobody is listening at and _NET_WM_USER_TIME is ignored. Harmless,
and worth knowing before concluding from a Mac that the feature is broken.
What is deliberately not here
- The launcher half. An app that spawns another app should generate an
id, send
new:, putDESKTOP_STARTUP_IDin the child's environment and close the sequence itself. Same encoder, different persona. - Ongoing
_NET_WM_USER_TIMEmaintenance. Setting it once at launch is this. Keeping it current on every keypress is a separate design with a real cost — EWMH is explicit that storing a frequently-changing property on the toplevel wakes every client watching that window, which is what_NET_WM_USER_TIME_WINDOWexists to avoid.
Security
The id is broadcast to the root window, so every client on the display sees it. On X11 that is the pre-existing no-isolation story rather than a new exposure — see security.md — but it is the reason the messages carry the id the launcher gave us and nothing invented, and the reason the variable's value is never logged.
The launcher: a badge, attention, and the Dock menu
The one thing an app says to the desktop that the user reads without opening it is the mark on its icon — an unread count on the Dock tile, a dot on the taskbar entry. Three things live here, and they are on both backends only where both have a mechanism:
| Linux | macOS (cocoa backend) | |
|---|---|---|
| a badge | com.canonical.Unity.LauncherEntry — KDE, elementary, Cairo-Dock listen; GNOME needs an extension | NSDockTile.badgeLabel |
| attention | states={['demands_attention']} — the urgency the taskbar blinks for | the same prop: the Dock icon bounces until activated |
| the Dock menu | desktop actions in the .desktop file — an install step | useDockMenu(items) |
The badge
import { setBadge, useBadge } from 'react-x11';
useBadge(unread); // a count: 3 shows "3", 0 clears
await setBadge(null); // the imperative twin, for code with no component
A badge is a count, because that is the one shape both desktops agree
on: a number shows on both. A string ('•', '!') shows on macOS, where the
tile takes any label, and is a visible count of nothing on Linux, where the
protocol has no text field — pass one only where the Mac is the audience.
0, null and '' all clear it. useBadge clears on unmount; setBadge
resolves to whether a launcher was told and never rejects for anything about
the machine.
On Linux the count travels as one D-Bus signal, LauncherEntry.Update,
attributed to the app by application://<appId>.desktop — so it needs the
identity registerApplication({ appId }) establishes and
a .desktop file of that name, or the launcher has nothing to pin the
count to and setBadge resolves false. The entry stays on the bus while
a badge is shown and is released when it is cleared. A launcher that starts
after the badge was set will not see it until the next setBadge; the
protocol has no query.
Attention
Nothing new to call: <window states={['demands_attention']}> is the
existing request, and on the cocoa backend it is
NSApp.requestUserAttention — the Dock icon bounces until the user
activates the app, and stops when the prop drops the state or the window
goes away. AppKit ignores the request while the app is already active, which
is the same outcome a window manager gives a focused window's urgency hint.
The other states names are inert on that backend until the bridge grows
zoom, miniaturize and fullscreen.
The Dock menu
useDockMenu([
{ label: 'New Window', onSelect: openWindow },
{ type: 'separator' },
{
label: 'Recent',
items: recent.map((r) => ({ label: r.name, onSelect: () => open(r) })),
},
]);
The same items vocabulary MenuBar and ContextMenu take (menuitem
names), so one authoring model covers the window's menu, the
panel's, the tray's and the Dock's. Installed while the component is mounted,
replaced when items changes, taken down on unmount. Inert off the cocoa
backend, with a development note the first time: the freedesktop counterpart
— Actions= in the .desktop file — is an install step, not runtime code.
The app's presence
Two things the Dock and the ⌘-Tab switcher show that a bare node process
gets wrong, as root options on the cocoa backend:
await createRoot({
cocoa: {
appName: 'Notes', // what the Dock, ⌘-Tab and the app menu print (was "node")
activationPolicy: 'regular', // 'accessory' for a menu-bar app: no tile, no ⌘-Tab entry
},
});
activationPolicy is fixed before the app finishes launching — a Regular
launch registers a Dock tile, and an agent app that switched afterwards would
already have flashed its icon — so it is a root option rather than a hook.
appName renames LaunchServices' record of an unbundled process; a bundle's
Info.plist wins, as it should.
The tray
import { useTray } from 'react-x11';
const { available } = useTray({
icon: 'bell.badge', // an SF Symbol name, or the bytes of a PNG
tooltip: 'Notifications',
menu: [
{ label: 'Open', onSelect: open },
{ type: 'separator' },
{ label: 'Quit', onSelect: quit },
],
});
An icon in the system tray for as long as the component is mounted. With
menu a click opens it — the same items vocabulary as MenuBar and the
Dock menu, so the three menus an app puts on the desktop are one authoring
model. Without one, onClick gets the button and the item's screen rect,
which is where to anchor a popup of your own. title shows text beside the
icon or alone; visible, tooltip and the rest follow their values while
mounted, and the item is removed on unmount. null means no item.
A PNG icon is drawn as a template — its shape in the bar's ink, so it
follows light and dark the way a symbol does; template: false keeps its
colours. Several trays coexist: each is its own item.
On the cocoa backend this is NSStatusItem, the menu-bar extra. A
menu-bar app that wants no Dock tile pairs it with
createRoot({ cocoa: { activationPolicy: 'accessory' } }) above.
On X11 it is inert, and says so: available is false and the hook
warns once in development. The freedesktop tray is StatusNotifierItem over
D-Bus, whose menu is the dbusmenu this renderer already
speaks — that half is
#353's open question,
and available is the seam that keeps an app's tray feature behind the
answer until it lands.
Turning the desktop off
Three things react-x11 turns on for you reach the session bus, and none of them was asked for:
appearance | following the desktop's light/dark, accent, contrast and reduced motion | appearance.md |
a11y | the AT-SPI bridge — whether a screen reader can see the app | accessibility.md |
globalMenu | a MenuBar handing its menu to the panel instead of drawing it | globalmenu.md |
Each is the right default: an app that follows the desktop looks like it
belongs there, one a screen reader can read is one more person can use, and a
menu in the panel is what the panel is for. But sooner or later an embedder
owns the thing we assumed was ours, and until
#417 the only way out was
an environment variable — NO_AT_BRIDGE=1,
REACT_X11_NO_GLOBAL_MENU=1, or unsetting DBUS_SESSION_BUS_ADDRESS. That is
a seam an app cannot reach for itself: the environment is inherited, so
setting one before createRoot() sets it for every child process too, and the
D-Bus one takes the portals and the app's own exported services down with the
follower.
await createRoot({ desktop: false }); // none of the three
await createRoot({ desktop: { appearance: false } }); // just that one
With all three off, nothing dials the session bus at startup — no connection, no subprocess, no portal probe. That is the shape an embedder wants, and a kiosk, and a test that needs the same answer on every machine.
Turning appearance off means the app draws in its own palette rather than
the desktop's: useSystemAppearance() reports 'no-preference' with
source: null, and the remembered answer on disk is not read either — an
opted-out app that started in whatever colours the machine was in last time
would be the opposite of what the switch is for.
Off is process-wide, and it latches. There is one desktop, one D-Bus identity and one AT-SPI bridge per process, so the policy cannot honestly be per-root: a second root turning back on what the first turned off would be a bug rather than a feature. Nothing turns an integration back on, and a feature already started stays started — pass this on the first root.
Not blocking the first frame on the bus
Worth knowing because it is invisible when it works, and was worth ~150 ms when it did not.
On macOS the session bus address is not in the environment; D-Bus advertises
it through launchd, and finding it means running launchctl getenv.
dbus-native's entry point is synchronous, so it has to do that with
spawnSync — 120–150 ms of blocked event loop on a cold page cache, landing
in front of the X handshake, the layout engine's WebAssembly and the first
paint, all so the accessibility bridge could find out there was nothing to
connect to.
Every dial now goes through one asynchronous, once-per-process lookup instead,
and hands dbus-native a resolved unix:path=…. The same wall clock is
spent, none of it on the loop: the handshake and the layout engine run
straight through it. There is nothing to configure — it is the same address,
found the same way, off the critical path.
Linux is unaffected: $DBUS_SESSION_BUS_ADDRESS or $XDG_RUNTIME_DIR/bus
answers without a subprocess, and always did.
Password fields and password managers
There is no password-field protocol on the Linux desktop. No toolkit
publishes "this is a password field, fill it", and no manager asks. What
exists instead are four seams, none of which a widget can opt into by
declaring itself — they are things an application either supports or does
not. PasswordInput supports the two that reach a field, and this is what
they turn out to be.
1. Typing — XTEST auto-type, and the keymap race
The mechanism nearly every desktop manager uses is auto-type: KeePassXC
matches the focused window's title against its entries, then synthesises
the keystrokes. Its X11 backend is the whole of the story — SendKeyEvent()
sends XTestFakeKeyEvent(), and for a character the current layout cannot
type, RemapKeycode() writes the keysym into a spare keycode with
XkbSetMap() first, XSyncing before it types.
For us that is good news and one hazard:
- A faked key is an ordinary key. XTEST events are delivered by the
server through the normal event path, so
PasswordInput— and every other focusable node — cannot tell auto-type from a person, and nothing has to be done to support it. An auto-type sequence of{USERNAME}{TAB}{PASSWORD} {ENTER}walks a react-x11 form because Tab moves focus and Enter reachesonSubmit. - The window needs a title worth matching. Auto-type's default matching is
on the window title, so
<window title="…">is the integration surface. A window titled after the document with nothing identifying the app is one a user cannot write an auto-type rule for;wmClassis worth setting too, since a manager that grew a smarter matcher would read that. - The keymap race is real. ntk refetches the mapping when the server sends
MappingNotify, which is what makes a remapped keycode decode correctly — but the refetch is a round trip, and a manager that remaps, syncs and types immediately can land its key before the reply does. The window is small and only affects characters outside the user's layout, but a password of ASCII is not the case that fails. Nothing here can close it; the fix belongs where the map lives.
2. Pasting — the clipboard, and the hint that keeps it out of history
The other path every manager offers is copy-to-clipboard, usually with a
countdown before it clears. That is ordinary CLIPBOARD interop
(clipboard.md); PasswordInput takes Ctrl+V and
Shift+Insert, and strips control characters so a manager's trailing newline
does not end up inside the secret.
If your app ever puts a secret on the clipboard — this widget never does
— offer x-kde-passwordManagerHint with the value secret beside the text.
Klipper drops such an offer from its history, KDE Connect refuses to forward
it, and wl-clipboard marks the state sensitive. It is a convention rather
than a specification, and it is the only thing standing between a copied
password and a clipboard manager's on-disk history:
clipboard.write({ UTF8_STRING: secret, 'x-kde-passwordManagerHint': 'secret' });
PasswordInput also never takes the PRIMARY selection, which a
<textinput> does on every selection: PRIMARY is pasted by a middle click in
any window on the display, and a secret does not belong in a selection that
can be spent by accident.
3. Fetching it yourself — the Secret Service
When the app has an account of its own to unlock, the seam is not a field at
all: the Secret Service D-Bus API, org.freedesktop.secrets, which
gnome-keyring, KWallet and KeePassXC all implement, and which libsecret
speaks for C applications. react-x11 has no wrapper for it and does not need
one — useSessionBus() reaches it directly (dbus.md) — and the
shape is: search by attributes, unlock the collection if it is locked, read
the secret back. An app that does this shows no password field on most
launches, which is a better outcome than any field can offer.
Inside a sandbox that name is not there. Flatpak's answer is
org.freedesktop.portal.Secret, whose RetrieveSecret hands the app a
per-application master secret down a pipe; libsecret switches to it
automatically and encrypts a local store with it.
4. Reading the field — AT-SPI, which we do not implement
The accessibility bus is the only channel through which an outside program
can see a field's contents and role rather than guess at a window title:
AT-SPI2 gives a password entry the role password text, which is how a
screen reader knows to announce "bullet" instead of the character. Some
automation leans on the same tree. react-x11 has no accessibility tree yet
(NEXT_STEPS §11.3) — role is a prop the testing queries read and nothing
else — so this seam is closed here for now, and worth reopening as one piece
with the rest of AT-SPI rather than as a password-shaped hole in it.
What a field can still do wrong
None of the above stops the field itself from leaking. What PasswordInput
does about that, and what it cannot:
- the masked value is never laid out or drawn — no glyphs of the secret reach ntk's shaping cache or the X server, and the mask's width is measured from one reference character;
- there is no copy, no selection, no undo history;
- the value is still a JavaScript string, and strings are immutable. It cannot be zeroed, it lives until the garbage collector takes it, and every intermediate value typed on the way lives alongside it. A design that needs more than that needs the secret to never enter this process.