slint-ui/slint · critical
SystemTrayIcon must be created on the main thread on macOS
Error message
SystemTrayIcon must be created on the main thread on macOS
What it means
On macOS the system tray is implemented with AppKit (NSStatusItem), and AppKit objects may only be touched from the process's main thread. objc2's MainThreadMarker::new() returns None when called off the main thread, so PlatformTray::new() panics via .expect('SystemTrayIcon must be created on the main thread on macOS') the moment a SystemTrayIcon is created anywhere else. This is a hard platform constraint, not a transient error.
Source
Thrown at internal/core/items/system_tray/appkit.rs:203
// ---------------------------------------------------------------------------
// PlatformTray: one per SystemTrayIcon item.
// ---------------------------------------------------------------------------
pub struct PlatformTray {
status_item: Retained<NSStatusItem>,
action_target: Retained<MenuAction>,
appearance_observer: Retained<AppearanceObserver>,
mtm: MainThreadMarker,
}
impl PlatformTray {
pub fn new(
params: Params,
self_weak: ItemWeak,
_context: &crate::SlintContext,
) -> Result<Self, Error> {
let mtm = MainThreadMarker::new()
.expect("SystemTrayIcon must be created on the main thread on macOS");
let image = image_to_nsimage(params.icon)?;
let status_bar = NSStatusBar::systemStatusBar();
let status_item = status_bar.statusItemWithLength(NSVariableStatusItemLength);
let action_target = MenuAction::new(mtm, self_weak.clone());
if let Some(button) = status_item.button(mtm) {
button.setImage(Some(&image));
let tooltip = NSString::from_str(params.tooltip);
button.setToolTip(Some(&tooltip));
// Slint's `title` is the visible text next to the icon (think
// battery percentage or system clock). Setting an empty string
// simply leaves no label, which is the natural default.
let title = NSString::from_str(params.title);
button.setTitle(&title);
// Route clicks back to slint's `clicked` callback. NSStatusItemView on GitHub (pinned to 3fd8f2ec03)
Solutions
- Create the SystemTrayIcon on the main thread - before slint::run_event_loop() is started there, or from a closure dispatched to the event loop thread (slint::invoke_from_event_loop).
- Keep all UI construction (windows, tray, component instantiation) on the same thread that runs run_event_loop().
- Restructure startup so worker threads are spawned after tray/window creation, not around it.
- If the embedding forces off-main init, move only data work to threads and hand UI handles back to main.
Example fix
// before: component (with a SystemTrayIcon element) created off the main thread
std::thread::spawn(|| {
let ui = App::new().unwrap(); // PANIC on macOS: AppKit is main-thread-only
});
// after: instantiate on the main thread that also runs the event loop
fn main() {
let ui = App::new().unwrap(); // main thread: tray creation succeeds
ui.run().unwrap(); // event loop stays on that same thread
} Defensive patterns
Strategy: validation
Validate before calling
#[cfg(target_os = "macos")]
fn on_main_thread() -> bool {
// mirrors what objc2's MainThreadMarker checks
unsafe { libc::pthread_main_np() != 0 }
}
#[cfg(target_os = "macos")]
assert!(on_main_thread(), "create the SystemTrayIcon on the main thread");
let ui = App::new().unwrap(); // component containing the tray element Try / catch
// Catching the panic does NOT make AppKit usable off-main;
// only use this to log and retry creation on the main thread:
let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
create_tray_here()
}));
if attempt.is_err() {
slint::invoke_from_event_loop(create_tray_here).ok(); // re-create on main
} Prevention
- Create all UI (windows, tray, component instances) on the thread that will run slint::run_event_loop
- On macOS assume every AppKit-backed object is main-thread-only
- Put tray setup at the top of main() before spawning any worker threads
- Add a startup debug_assert on thread identity on macOS
When it happens
Trigger: Instantiating a component containing a SystemTrayIcon (or otherwise creating the tray) inside std::thread::spawn, a tokio worker thread, or any background thread on macOS; an embedding (Python/Node/host app) initializing the Slint UI on a side thread instead of the one that runs the event loop.
Common situations: Porting from Linux/Windows where tray creation in a worker thread appeared to work; apps that spawn helper threads at startup and let tray setup land on one of them; test harnesses that construct components off the main thread; CI running macOS runners with multi-threaded bootstrap code.
Related errors
- Polling completed or aborted JoinHandle
- unable to show popup window
- fatal: swash is unable to parse truetype font
AI-assisted analysis of slint-ui/slint@3fd8f2ec03 (2026-08-19).
Data as JSON: /api/errors/b9343e48e5e7ce31.
Report an issue: GitHub.