fix(windows): create the tray when the Qt thread is actually registered

The icon never appeared, and the log said NOTHING -- not even
"[tray] disabled by user setting", which is what `init_gates` prints when it
refuses. That absence is what identified it: the gates had passed and the work
itself had vanished.

`tray_bridge::ui` silently discards its closure until `QbzTray::boot` registers
the Qt thread, and `tray_qt::init` runs from `on_session_entered`, which
reaches it first. Every log line in that arm lives inside the closure, so
nothing was left to see.

`init` now records the intent and still tries the hop; `boot` calls the same
idempotent `create_now()` right after registering the thread. Whichever arrives
SECOND does the work. No polling and nothing to time out.

Verified on the box by differential capture rather than by the log: with QBZ
running the notification-area overflow holds eight icons, without it seven, and
the one that disappears is QBZ's. `HKCU\Control Panel\NotifyIconSettings` has
the entry with `InitialTooltip: QBZ`. It sits in the overflow because
`IsPromoted` is unset, which is what Windows 11 does with every newly seen app.

The macOS arm has the same shape and is NOT touched here: if `init` wins that
race there, the NSStatusItem is never created, nothing logs, and `tray_live`
keeps its derived false so close-to-tray stops working too. Recorded for the
owner rather than changed from a Windows branch, since the structural fix
(buffering early work in `tray_bridge::ui`) would also move Linux, where early
calls are dropped by design.

cc
This commit is contained in:
vicrodh committed 2026-08-27 14:20:03 -06:00
1 parent 1a2a9994dd
commit d965f6a1d9
2 files changed
+46 -14

No files matched your search

+5
View File
@@ -180,6 +180,11 @@ impl qbz_tray::QbzTray {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] tray Qt thread already registered");
}
// If `tray_qt::init` ran before this point its queued closure was
// dropped on the floor -- `ui()` above is a no-op until the line
// right here. Pick the work up now; we are already on the GUI thread.
#[cfg(target_os = "windows")]
crate::tray_qt::create_now();
#[cfg(target_os = "linux")]
crate::single_instance_qt::bind_ui();
}
+41 -14
View File
@@ -416,23 +416,24 @@ pub(crate) fn init(theme_override: String, enabled: bool) {
// Windows: Shell_NotifyIconW owns a hidden window with a message loop,
// so the icon must be created on the thread that pumps it -- the Qt GUI
// thread. Same hop as macOS, and for the same class of reason.
// thread.
//
// The hop ALONE is not enough, and the first version of this shipped with
// exactly that bug: `tray_bridge::ui` silently discards work until
// `QbzTray::boot` registers the Qt thread, and this runs from
// `on_session_entered`, which reaches it first. The symptom was a tray that
// never appeared and a log with no `[tray]` line at all -- not even the
// "disabled by user setting" the gates print when they refuse, which is
// what pinned it down.
//
// So: record the intent, then try. Whichever of the two arrives SECOND --
// this call or `boot` -- does the work, and `create_now` is idempotent.
// No polling, and nothing to time out.
#[cfg(target_os = "windows")]
{
let _ = theme_override;
crate::tray_bridge::ui(move |_b| {
let ok = crate::tray_windows::create();
// Both arms publish, exactly as Linux and macOS do: a failure MUST
// report false, or close-to-tray hides the window into a tray that
// is not there.
if ok {
let _ = TRAY.set(TrayHandle { windows: true });
push_tray_live(true);
} else {
log::error!("[tray] Windows notification-area icon could not be created");
push_tray_live(false);
}
});
TRAY_WANTED.store(true, Ordering::SeqCst);
crate::tray_bridge::ui(|_b| create_now());
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
@@ -442,6 +443,32 @@ pub(crate) fn init(theme_override: String, enabled: bool) {
}
}
/// Set once `init` has decided a tray SHOULD exist. Read by
/// `QbzTray::boot`, which creates it if `init` ran before the Qt thread was
/// registered and its queued closure went nowhere.
#[cfg(target_os = "windows")]
static TRAY_WANTED: AtomicBool = AtomicBool::new(false);
/// Create the Windows tray. GUI THREAD ONLY, and idempotent: both `init`'s
/// hop and `QbzTray::boot` call it, and whichever runs first wins.
#[cfg(target_os = "windows")]
pub(crate) fn create_now() {
if !TRAY_WANTED.load(Ordering::SeqCst) || TRAY.get().is_some() {
return;
}
let ok = crate::tray_windows::create();
// Both arms publish, exactly as Linux and macOS do: a failure MUST report
// false, or close-to-tray hides the window into a tray that is not there.
if ok {
let _ = TRAY.set(TrayHandle { windows: true });
push_tray_live(true);
log::info!("[tray] Windows notification-area icon created");
} else {
log::error!("[tray] Windows notification-area icon could not be created");
push_tray_live(false);
}
}
/// True when running under gamescope (Steam Deck Game Mode). Stateless, and a
/// verbatim port of `crates/qbz/src/main.rs:8333-8336` — the raw
/// `GAMESCOPE_WAYLAND_DISPLAY` env is inherited by a re-spawn, and