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. NSStatusItem

View on GitHub (pinned to 3fd8f2ec03)

Solutions

  1. 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).
  2. Keep all UI construction (windows, tray, component instantiation) on the same thread that runs run_event_loop().
  3. Restructure startup so worker threads are spawned after tray/window creation, not around it.
  4. 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

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


AI-assisted analysis of slint-ui/slint@3fd8f2ec03 (2026-08-19). Data as JSON: /api/errors/b9343e48e5e7ce31. Report an issue: GitHub.