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) first registration wins (the slot is a OnceCell), so dioxus's tray-menu clicks arrive on the menubar hook and use_tray_menu_event_handler never fires (dioxus#4495; an earlier draft here had the direction inverted — corrected 2026-08-30); (c) floem works only because its muda 0.17 and tray-icon 0.24's muda 0.19 are separate compiled instances — pull tray-icon 0.21 (muda 0.17) instead and cargo unifies them; version divergence is load-bearing for correctness right now. A facade needs per-menu closures, not a global static.
T3 · Use-after-free on first menu click (macOS; muda 0.17.2 and 0.19.3 have identical code): raw *const MenuChild stored unretained; drop the owning Menu/Submenu after init_for_nsapp and the first click reads freed memory. Reported as muda #202/#233, fixed on main by PR #361 (2026-07-30), unreleased as of 2026-08-30. 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 lets gtk-rs panic ("GTK has not been initialized", gtk-rs's assert_initialized_main_thread!) at the first gtk::Menu::new instead of checking gtk::is_initialized() and returning Err. 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 (tracked upstream: global-hotkey #28/#162); a set_event_handler callback exists but nine of ten integrations poll; Pressed and Released both arrive (documented); cross-process contention was not characterised (two apps saw every registrant fire), so the facade must define that contract.
T10 · From an unbundled binary, mac-notification-sys resolves bundle id "use_default" by running the AppleScript get id of application "use_default" (not LaunchServices — corrected 2026-08-30). macOS 26 then pops AppleScript's blocking "Where is use_default?" chooser while .show() returns Ok (hit by freya, vizia, xilem independently; upstream mac-notification-sys #81).
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) avoids the abort but not the identity problem, so unbundled binaries still get no banner. egui also saw eframe stop scheduling frames after the first notification (2 of 3 runs; cause unproven — source rules out the earlier "delegate replacement" theory and points at the same run-loop pumping) and rejected the crate. Three frameworks shipped osascript subprocesses instead.
T12 · Ok(()) proves nothing about a banner — in the pinned notify-rust 4.18 the macOS show() returns Ok before sending; the send happens in Drop and its error is discarded. (The same version already ships a UserNotifications backend behind preview-macos-un; none of our apps enabled it.) 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); none of the ten apps drops the Dock icon, though 6 of 10 frameworks expose a route to ActivationPolicy::Accessory (tauri, eframe, slint, xilem, freya, dioxus; not iced, vizia, floem, gpui — an earlier draft's "8 of 10 cannot" was wrong, corrected 2026-08-30); 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 (corrected 2026-08-30): nine of ten integrations drain the crates' receivers from a 50–100 ms timer pump (iced needs a non-default executor feature just to have a timer). The crates do ship set_event_handler callbacks — the real constraint is that each is a process-global OnceCell (T2), so a framework that claims it leaves the app with the receiver. Delivery must be per-menu callbacks 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.