Requirements and traps for a framework-neutral OS-shell layer, "Tauri plugins, but for winit". Distilled from one shell-integration app built in ten Rust GUI frameworks, plus a headless-Linux probe round. Every trap below was hit in one of those builds.
Nine of ten frameworks assemble the same five crates (tray-icon, muda, global-hotkey, rfd, arboard, plus notify-rust), and every integration re-discovers the same traps. On Linux the layer is GTK-coupled and Wayland-unserved; winit itself has no menu/tray API (winit#403, open since 2018). The protocol pieces exist in Rust. ksni implements StatusNotifierItem, ashpd covers the GlobalShortcuts portal, and DBusMenu crates exist. Nobody maintains a framework-neutral facade that puts them together.
Err. Build-only CI passes.These ten implementations are the reference answers. All ten achieved every capability on macOS (zero not-achievable cells). The Linux column is what the facade must fix. The Windows column, measured 2026-08-08 on one machine and written up in report/21, starts further back. Three of the ten frameworks' tray apps never compiled, and only the tray, hotkey, toast and dark-mode cells were exercised.
| Capability | Acceptance test | macOS in corpus | Linux in corpus | Windows in corpus |
|---|---|---|---|---|
| System tray + menu | icon visible; clicks delivered; live label update | 10/10 | panics or silently invisible | 5/7 built apps rantauri/freya/vizia never built; egui/xilem died on the hotkey panic; icon evidence: iced tray-shot only |
| Global hotkey | chord fires while app unfocused | 10/10 | works on X11Wayland unserved | 0/7 registeredWin+Shift+9 already held by the OS taskbar (desktop-state-dependent); egui/xilem panic, the other five log + continue |
| Native menubar | real OS menubar; working Edit accelerators | 10/10 via trap T1 | impossible on plain winit (E0277) | untested |
| Native dialogs | open/save panels without blocking the loop | 10/10 | untestedrfd XDG-portal is the candidate | untested |
| Clipboard text + image | paste a screenshot into the window | 10/10 | untested | untestedonly probe: dioxus paste_image correctly errored on an empty clipboard |
| File drop | window receives real paths from the file manager | 10/10 (event-level) | untested | untested |
| Notification | banner attributed to the app | weakest celltraps T10–T12 | untested | posted (dioxus)notify-rust toast from a bare unsigned exe, which refutes the AUMID/shortcut risk note |
| Live dark mode | palette follows the OS without restart | 10/10 | untested | dark theme detected (iced, floem)theme-changed events logged; no live OS toggle exercised |
| Close-to-tray + reopen | close hides; tray/Dock restores; process survives 0 windows | 10/10Dock-reopen: floem/tauri only | untested | untested |
Three extra acceptance rules.
T1 · Predefined Edit roles: dead selectors + stolen key equivalents. PredefinedMenuItem::{cut,copy,paste}
dispatch Cocoa responder-chain selectors no winit view implements. The items no-op, and NSMenu consumes ⌘X/⌘C/⌘V before the
focused widget sees them. Paste breaks app-wide, silently. Every fix re-injects synthetic clipboard events into one hard-coded widget.
T2 · One global MenuEvent handler slot, three failure shapes. (a) a separately-resolved muda
owns a different static channel, so clicks vanish; (b) last registration wins, so dioxus's menubar events arrive as tray
events; (c) floem works only because its muda 0.17 and tray-icon's muda 0.19 are separate compiled instances, which
makes version divergence load-bearing for correctness right now. A facade needs per-menu closures, not a global static.
T3 · Use-after-free on first menu click (muda 0.17): raw *const MenuChild stored unretained;
the idiomatic append-then-init shape reads freed memory on the first click. Menus must own their items.
T4 · Creation ordering. Main thread, after the run loop starts, with a !Send handle. Every framework
paid differently (boot task, creator closure, external-loop embedding, timer tick). The API must enforce the contract, not document it.
T5 · Linux menubar on plain winit is a compile error. Menu::init_for_gtk_window requires
W: IsA<gtk::Window> (E0277 retained). This is the hard boundary a DBusMenu backend removes.
T6 · muda panics ("GTK has not been initialized") at the first gtk::Menu::new. macOS-written
apps compile unchanged and die at launch; graceful-degradation code never runs.
T7 · Bare TrayIconBuilder::build without GTK returns Ok for an icon that can never
function; with GTK but no StatusNotifier host on the bus (DBus NameHasOwner → false, captured) everything still
reports success. Zero feedback either way.
T8 · tray-icon's winit path requires a parallel GTK event loop on a second thread (its own example does this). ksni removes the GTK loop entirely.
T9 · global-hotkey is X11-only (though there it worked end-to-end: registration and delivery of an xdotool-fired chord, probe retained). No Wayland portal; delivery is poll-only; Pressed and Released both arrive; observed cross-process contention behavior was inconsistent between apps, so the facade must define that contract.
T10 · From an unbundled binary, mac-notification-sys resolves bundle id "use_default" via
LaunchServices. macOS 26 then pops a blocking "Where is use_default?" chooser while .show() returns
Ok (hit by freya, vizia, xilem independently).
T11 · The helper pumps the main runloop: from inside a winit callback → panic-abort
("tried to handle event while another event is currently being handled", xilem + vizia); from a slint::Timer
callback → abort ("Recursion in timer code"). The safe shape (detached thread) then silently drops the banner for
unbundled binaries. egui also saw eframe stop scheduling frames after the first notification (2 of 3 runs; cause
unproven) and rejected the crate. Three frameworks shipped osascript subprocesses instead.
T12 · Ok(()) proves nothing about a banner. Acceptance tests must observe display, from a
bundled, identified .app. The facade must be identity-aware and fail loud from bare binaries.
T13 · Hide/reopen is where frameworks leak: eframe never runs App::ui for hidden viewports
(first attempt: a window that could never come back); gpui's app-level hide is silently ignored during NSMenu dismissal
(300 ms settle-delay fix); 8 of 10 frameworks cannot drop the Dock icon (ActivationPolicy::Accessory unreachable);
only floem and tauri expose Dock-icon reopen.
T14 · Clipboard images: arboard hands back raw RGBA and four frameworks PNG-encode it again just to
display it. gpui's multi-entry clipboard with native image decode (ClipboardEntry::Image) is the reference shape.
T15 · The polling tax: no tray/menu/hotkey channel has waker integration, so the standard integration is a
50–100 ms timer pump (iced needs a non-default executor feature just to have a timer). Delivery must be callback/waker-based
with a main-thread bridge. floem's ExtSendTrigger/create_ext_action is the tested reference.
MenuItem::os_action routes Cut/Copy/Paste to the focused editor's actions; accelerators derive from the keymap.ExtSendTrigger is the only first-class foreign-thread wakeup in the cohort, exactly the delivery mechanism T15 asks for. Plus AppEvent::Reopen.tray feature registers muda/tray-icon's global handlers itself and forwards into one typed callback. The app never links its own muda copy, so the channel-splitting trap cannot happen, and creation ordering is handled by construction.SystemTrayIcon + MenuBar as DSL elements make the shell ~80% declarative, with reactive menu labels. That's the shape to copy.Baselines to beat (same spec, LoC incl. markup): dioxus 394 · slint 447 · egui 514 · tauri 559 · floem 610 · iced 682 · vizia 720 · xilem 774 · freya 865 · gpui 1,575. Shell integration grew dependency trees by 8–112 crates per framework. A facade should get the glue closer to the dioxus/slint LoC without forcing a webview.
muda_on_winit gives E0277 at compile time. No winit-facing menubar attach API exists on Linux.tray_without_gtk returns Ok for a non-functional icon (GTK criticals on stderr only).tray_with_gtk_no_host is a silent success with no StatusNotifier host on the bus.global_hotkey_x11 worked end-to-end (registration + xdotool-fired delivery).NameHasOwner check the probe used, and return Err(NoHost) instead of a silent success.Err without GTK; retain menu items; document the Edit-role constraint). The trap catalog is the issue list, and filing it has value independent of any facade.