feat(windows): system tray via Shell_NotifyIconW
The third sibling of the Linux ksni item and the macOS NSStatusItem, wired into the same `tray_qt` seams and dispatchers, so a tray action behaves identically on all three. Close-to-tray needs no QML change: `Main.qml:786` already keys on `trayLive`, which both arms of `init` publish. The menu is the SAME menu. The plan's C ABI carried no labels, which would have shipped an English-only menu in an app with eight locales; Linux and macOS already share five `qbz_i18n` keys, so the translated strings are passed in from Rust and no new string is introduced. Win32 details that are load-bearing rather than incidental: - NIF_SHOWTIP. Version 4 SUPPRESSES the standard tooltip unless asked, so without it the icon appears, every track sets the tooltip, and nothing ever shows on hover. - The NIM_SETVERSION result is checked and creation fails if it is refused. The window procedure decodes callbacks as version 4 and nothing else, so a silent negotiation failure would leave a live icon whose clicks and menu do nothing -- worse than no icon, because close-to-tray would then hide the window behind it. - WS_EX_TOOLWINDOW, not HWND_MESSAGE. A message-only window never receives the broadcast TaskbarCreated, so the icon would never come back after an Explorer restart. The window is on the GUI thread, so Qt's own loop dispatches it. - LoadImageW without LR_SHARED returns an icon this process OWNS; it is destroyed on teardown, while the LoadIconW system fallback is not. - Failed creation gives back the window and the icon instead of parking them for the process lifetime, and create is idempotent. - NIM_SETFOCUS after the menu closes, which the Shell asks for and matters most when it was dismissed with Escape. Every mutation is marshalled through `tray_bridge::ui`, exactly as the macOS theme arm is. The publishers run on whatever thread playback pushed from -- the 1 Hz poll lives in `crate::spawn` -- while this tray keeps process-global C++ state and talks to a GUI-thread window; calling straight through was a data race, not merely a documentation mismatch. Linux needs no hop because ksni's handle is a thread-safe channel. Strings are truncated on the Rust side by UTF-16 CODE UNITS, not by `chars()`. The buffers are sized in `wchar_t`, an astral character costs two, and `wcsncpy_s` with `_TRUNCATE` would happily keep a high surrogate whose partner was cut -- malformed UTF-16 handed to the shell. The tray-icon-variant row is hidden on Windows: the notification area draws the executable's own icon and Windows owns light/dark, so the four choices would render, persist and change nothing. cc
This commit is contained in:
8 files changed
+625
-5
No files matched your search
@@ -379,6 +379,11 @@ fn build_rhi_items() {
|
||||
cc.file("cxx/font_fallback.cpp");
|
||||
println!("cargo:rerun-if-changed=cxx/win_shell.cpp");
|
||||
cc.file("cxx/win_shell.cpp");
|
||||
// Shell_NotifyIconW tray. No Q_OBJECT, so no moc; the body is inside
|
||||
// `#ifdef _WIN32` and compiles to nothing on Linux and macOS.
|
||||
println!("cargo:rerun-if-changed=cxx/win_tray.cpp");
|
||||
println!("cargo:rerun-if-changed=cxx/win_tray.h");
|
||||
cc.file("cxx/win_tray.cpp");
|
||||
for p in qtbuild.include_paths() {
|
||||
cc.include(p);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Windows notification-area icon, modelled on Qt's own
|
||||
// qwindowssystemtrayicon.cpp. The whole body is inside `#ifdef _WIN32` so the
|
||||
// file can sit unconditionally in build.rs beside font_fallback.cpp and
|
||||
// compile to nothing on Linux and macOS.
|
||||
|
||||
#include "win_tray.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
// ORDER MATTERS, and it is not alphabetical. <windows.h> must come first:
|
||||
// <shellapi.h> uses DECLSPEC_IMPORT, HDROP and EXTERN_C without declaring
|
||||
// them, so including it first produces a wall of C4430/C2146 inside the SDK
|
||||
// header itself and points nowhere near this file.
|
||||
#include <windows.h>
|
||||
|
||||
#include <shellapi.h>
|
||||
// GET_X_LPARAM / GET_Y_LPARAM: signed extraction, which matters on a
|
||||
// multi-monitor desktop where a secondary display has negative coordinates.
|
||||
#include <windowsx.h>
|
||||
|
||||
#pragma comment(lib, "shell32.lib")
|
||||
#pragma comment(lib, "user32.lib")
|
||||
|
||||
namespace {
|
||||
|
||||
// WM_APP+101: the tray's callback message. Anything in the WM_APP range is
|
||||
// ours to define, and this window class receives nothing else.
|
||||
constexpr UINT kTrayCallback = WM_APP + 101;
|
||||
constexpr UINT kIconId = 1;
|
||||
|
||||
enum MenuId : UINT {
|
||||
kMenuPlayPause = 1001,
|
||||
kMenuNext,
|
||||
kMenuPrevious,
|
||||
kMenuShowHide,
|
||||
kMenuQuit,
|
||||
};
|
||||
|
||||
HWND g_hwnd = nullptr;
|
||||
HICON g_icon = nullptr;
|
||||
// LoadImageW hands us an icon WE own; the LoadIconW(nullptr, ...) fallback is a
|
||||
// shared system icon that must never be destroyed. Which one we got decides
|
||||
// whether qbz_win_tray_destroy calls DestroyIcon.
|
||||
bool g_icon_owned = false;
|
||||
UINT g_taskbar_created = 0; // RegisterWindowMessageW("TaskbarCreated")
|
||||
bool g_playing = false;
|
||||
wchar_t g_tooltip[128] = L"QBZ";
|
||||
|
||||
wchar_t g_label_play_pause[64] = L"Play/Pause";
|
||||
wchar_t g_label_next[64] = L"Next Track";
|
||||
wchar_t g_label_previous[64] = L"Previous Track";
|
||||
wchar_t g_label_show_hide[64] = L"Show/Hide Window";
|
||||
wchar_t g_label_quit[64] = L"Quit QBZ";
|
||||
|
||||
void (*g_on_left_click)() = nullptr;
|
||||
void (*g_on_play_pause)() = nullptr;
|
||||
void (*g_on_next)() = nullptr;
|
||||
void (*g_on_previous)() = nullptr;
|
||||
void (*g_on_quit)() = nullptr;
|
||||
|
||||
void copy_label(wchar_t *dst, size_t cap, const wchar_t *src)
|
||||
{
|
||||
if (!src)
|
||||
return;
|
||||
wcsncpy_s(dst, cap, src, _TRUNCATE);
|
||||
}
|
||||
|
||||
// The icon Windows draws. winresource assigns id 1 to the first icon it
|
||||
// embeds (W4), and LoadImageW at 16x16 picks the matching RT_ICON out of that
|
||||
// group. If it is ever missing we fall back to the generic application icon
|
||||
// rather than returning null: a tray entry with the wrong picture is a cosmetic
|
||||
// bug, a tray entry that never appears is close-to-tray hiding the window with
|
||||
// no way back.
|
||||
//
|
||||
// Sets g_icon_owned: without LR_SHARED the LoadImageW handle is OURS and leaks
|
||||
// unless destroyed, while the LoadIconW fallback is shared and must not be.
|
||||
HICON load_tray_icon()
|
||||
{
|
||||
HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||
const int cx = GetSystemMetrics(SM_CXSMICON);
|
||||
const int cy = GetSystemMetrics(SM_CYSMICON);
|
||||
if (HICON icon = static_cast<HICON>(
|
||||
LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR))) {
|
||||
g_icon_owned = true;
|
||||
return icon;
|
||||
}
|
||||
g_icon_owned = false;
|
||||
// MAKEINTRESOURCEW, not IDI_APPLICATION: this build does not define
|
||||
// UNICODE, so the bare macro expands to the ANSI MAKEINTRESOURCEA and
|
||||
// will not convert to the LPCWSTR LoadIconW wants. 32512 IS
|
||||
// IDI_APPLICATION. Every Win32 call in this file is an explicit W for the
|
||||
// same reason.
|
||||
return LoadIconW(nullptr, MAKEINTRESOURCEW(32512));
|
||||
}
|
||||
|
||||
NOTIFYICONDATAW base_data()
|
||||
{
|
||||
NOTIFYICONDATAW nid = {};
|
||||
nid.cbSize = sizeof(nid);
|
||||
nid.hWnd = g_hwnd;
|
||||
nid.uID = kIconId;
|
||||
return nid;
|
||||
}
|
||||
|
||||
bool add_icon()
|
||||
{
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
// NIF_SHOWTIP is REQUIRED with version 4. Version 4 suppresses the standard
|
||||
// tooltip unless it is asked for, so without this flag the icon appears,
|
||||
// the tooltip is set on every track, and nothing ever shows on hover.
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP;
|
||||
nid.uCallbackMessage = kTrayCallback;
|
||||
nid.hIcon = g_icon;
|
||||
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
|
||||
if (!Shell_NotifyIconW(NIM_ADD, &nid))
|
||||
return false;
|
||||
|
||||
// Version 4 is what makes the callback carry the event in LOWORD(lParam)
|
||||
// and real screen coordinates in wParam. Without it the right-click
|
||||
// arrives as a bare WM_RBUTTONUP with no position and the menu opens in
|
||||
// the wrong place on a multi-monitor desktop.
|
||||
//
|
||||
// The result is CHECKED. wnd_proc decodes callbacks as version 4 and
|
||||
// nothing else, so a silent negotiation failure would leave a live icon
|
||||
// whose clicks and menu do nothing -- worse than no icon at all, because
|
||||
// close-to-tray would then hide the window behind it.
|
||||
NOTIFYICONDATAW ver = base_data();
|
||||
ver.uVersion = NOTIFYICON_VERSION_4;
|
||||
if (!Shell_NotifyIconW(NIM_SETVERSION, &ver)) {
|
||||
NOTIFYICONDATAW del = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &del);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void show_menu(int x, int y)
|
||||
{
|
||||
HMENU menu = CreatePopupMenu();
|
||||
if (!menu)
|
||||
return;
|
||||
|
||||
AppendMenuW(menu, MF_STRING, kMenuPlayPause, g_label_play_pause);
|
||||
AppendMenuW(menu, MF_STRING, kMenuPrevious, g_label_previous);
|
||||
AppendMenuW(menu, MF_STRING, kMenuNext, g_label_next);
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, kMenuShowHide, g_label_show_hide);
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, kMenuQuit, g_label_quit);
|
||||
|
||||
// The documented ritual. Without the SetForegroundWindow the menu does not
|
||||
// dismiss when the user clicks elsewhere; without the trailing PostMessage
|
||||
// it can stay up after a selection (MSDN TrackPopupMenu remarks).
|
||||
SetForegroundWindow(g_hwnd);
|
||||
const UINT cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY,
|
||||
x, y, 0, g_hwnd, nullptr);
|
||||
PostMessageW(g_hwnd, WM_NULL, 0, 0);
|
||||
DestroyMenu(menu);
|
||||
|
||||
// Hand keyboard focus back to the notification area, which the Shell
|
||||
// documentation asks for after a context menu closes -- particularly when
|
||||
// it was dismissed with Escape rather than a selection.
|
||||
NOTIFYICONDATAW focus = base_data();
|
||||
Shell_NotifyIconW(NIM_SETFOCUS, &focus);
|
||||
|
||||
switch (cmd) {
|
||||
case kMenuPlayPause:
|
||||
if (g_on_play_pause)
|
||||
g_on_play_pause();
|
||||
break;
|
||||
case kMenuNext:
|
||||
if (g_on_next)
|
||||
g_on_next();
|
||||
break;
|
||||
case kMenuPrevious:
|
||||
if (g_on_previous)
|
||||
g_on_previous();
|
||||
break;
|
||||
case kMenuShowHide:
|
||||
if (g_on_left_click)
|
||||
g_on_left_click();
|
||||
break;
|
||||
case kMenuQuit:
|
||||
if (g_on_quit)
|
||||
g_on_quit();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
||||
{
|
||||
// Explorer restarted (or crashed): every icon is gone from the new taskbar
|
||||
// and each app has to add its own back. Missing this is why a tray icon
|
||||
// "disappears forever" after an Explorer restart.
|
||||
if (msg == g_taskbar_created && g_taskbar_created != 0) {
|
||||
add_icon();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (msg == kTrayCallback) {
|
||||
// Version 4 packing: event in the low word of lParam, cursor position
|
||||
// in wParam.
|
||||
switch (LOWORD(lparam)) {
|
||||
case NIN_SELECT:
|
||||
case NIN_KEYSELECT:
|
||||
if (g_on_left_click)
|
||||
g_on_left_click();
|
||||
return 0;
|
||||
case WM_CONTEXTMENU:
|
||||
show_menu(GET_X_LPARAM(wparam), GET_Y_LPARAM(wparam));
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return DefWindowProcW(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
bool ensure_window()
|
||||
{
|
||||
if (g_hwnd)
|
||||
return true;
|
||||
|
||||
HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||
static const wchar_t *kClass = L"QbzTrayMessageWindow";
|
||||
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.lpfnWndProc = tray_wnd_proc;
|
||||
wc.hInstance = inst;
|
||||
wc.lpszClassName = kClass;
|
||||
// Re-registering the same class returns 0 with ERROR_CLASS_ALREADY_EXISTS,
|
||||
// which is fine on a second create.
|
||||
RegisterClassExW(&wc);
|
||||
|
||||
// NOT HWND_MESSAGE. A message-only window never receives the broadcast
|
||||
// TaskbarCreated, so the icon would never come back after an Explorer
|
||||
// restart. WS_EX_TOOLWINDOW keeps this invisible helper out of the taskbar
|
||||
// and the Alt+Tab list, which is the reason HWND_MESSAGE is tempting.
|
||||
g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kClass, L"QbzTrayMessageWindow",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, inst, nullptr);
|
||||
if (!g_hwnd)
|
||||
return false;
|
||||
|
||||
g_taskbar_created = RegisterWindowMessageW(L"TaskbarCreated");
|
||||
// A broadcast message is filtered out by UIPI unless the window opts in.
|
||||
if (g_taskbar_created)
|
||||
ChangeWindowMessageFilterEx(g_hwnd, g_taskbar_created, MSGFLT_ALLOW, nullptr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
|
||||
const wchar_t *previous, const wchar_t *show_hide,
|
||||
const wchar_t *quit)
|
||||
{
|
||||
copy_label(g_label_play_pause, 64, play_pause);
|
||||
copy_label(g_label_next, 64, next);
|
||||
copy_label(g_label_previous, 64, previous);
|
||||
copy_label(g_label_show_hide, 64, show_hide);
|
||||
copy_label(g_label_quit, 64, quit);
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
|
||||
void (*on_next)(), void (*on_previous)(),
|
||||
void (*on_quit)())
|
||||
{
|
||||
g_on_left_click = on_left_click;
|
||||
g_on_play_pause = on_play_pause;
|
||||
g_on_next = on_next;
|
||||
g_on_previous = on_previous;
|
||||
g_on_quit = on_quit;
|
||||
}
|
||||
|
||||
extern "C" bool qbz_win_tray_create(const wchar_t *tooltip)
|
||||
{
|
||||
if (tooltip)
|
||||
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
|
||||
|
||||
// Idempotent: a second create must not stack a second NIM_ADD on a live
|
||||
// icon (which fails and would report the whole tray dead while the first
|
||||
// icon is still sitting there). Remove the old one first.
|
||||
if (g_hwnd) {
|
||||
NOTIFYICONDATAW del = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &del);
|
||||
}
|
||||
|
||||
if (!ensure_window())
|
||||
return false;
|
||||
if (!g_icon)
|
||||
g_icon = load_tray_icon();
|
||||
|
||||
if (add_icon())
|
||||
return true;
|
||||
|
||||
// Failed. Give back the window and the icon rather than leaving them
|
||||
// parked for the life of the process: the Rust side will not retry, so
|
||||
// nothing else would ever free them.
|
||||
if (g_icon && g_icon_owned)
|
||||
DestroyIcon(g_icon);
|
||||
g_icon = nullptr;
|
||||
g_icon_owned = false;
|
||||
DestroyWindow(g_hwnd);
|
||||
g_hwnd = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_tooltip(const wchar_t *tooltip)
|
||||
{
|
||||
if (!tooltip)
|
||||
return;
|
||||
// szTip is 128 wchar INCLUDING the terminator; _TRUNCATE keeps the copy
|
||||
// safe and Windows simply elides the rest in the hover text.
|
||||
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
|
||||
if (!g_hwnd)
|
||||
return;
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
// NIF_SHOWTIP again: it is a per-call flag, not sticky state.
|
||||
nid.uFlags = NIF_TIP | NIF_SHOWTIP;
|
||||
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_playing(bool playing)
|
||||
{
|
||||
// Recorded for the menu; the label itself is the platform-neutral
|
||||
// "Play/Pause" the other two trays use, so nothing needs rebuilding here.
|
||||
g_playing = playing;
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_destroy(void)
|
||||
{
|
||||
if (!g_hwnd)
|
||||
return;
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||
DestroyWindow(g_hwnd);
|
||||
g_hwnd = nullptr;
|
||||
// LoadImageW WITHOUT LR_SHARED returns an icon this process owns, and it
|
||||
// leaks unless destroyed. Only the LoadIconW(nullptr, ...) fallback is a
|
||||
// shared system icon, which must never be destroyed.
|
||||
if (g_icon && g_icon_owned)
|
||||
DestroyIcon(g_icon);
|
||||
g_icon = nullptr;
|
||||
g_icon_owned = false;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,40 @@
|
||||
// Windows notification-area icon. C ABI so the Rust seam (src/tray_windows.rs)
|
||||
// can drive it exactly as tray_linux.rs drives the StatusNotifierItem and
|
||||
// tray_macos.rs drives the NSStatusItem.
|
||||
//
|
||||
// EVERY function here is GUI-THREAD ONLY. The icon owns a real (hidden) window
|
||||
// and a message loop; touching it from tokio would post to a thread that never
|
||||
// pumps.
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <wchar.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// The five menu labels, already translated by the Rust side (the same
|
||||
// qbz_i18n keys Linux and macOS use, so the three menus stay identical).
|
||||
// Must be called before qbz_win_tray_create; the strings are copied.
|
||||
void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
|
||||
const wchar_t *previous, const wchar_t *show_hide,
|
||||
const wchar_t *quit);
|
||||
|
||||
// Installed once, before create. Each maps 1:1 to a tray_qt dispatcher.
|
||||
void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
|
||||
void (*on_next)(), void (*on_previous)(),
|
||||
void (*on_quit)());
|
||||
|
||||
// NIM_ADD + NIM_SETVERSION(4). False if the message window or the icon could
|
||||
// not be created -- the caller must then leave the tray handle empty, or
|
||||
// close-to-tray would hide the window with nothing to restore it from.
|
||||
bool qbz_win_tray_create(const wchar_t *tooltip);
|
||||
|
||||
void qbz_win_tray_set_tooltip(const wchar_t *tooltip);
|
||||
void qbz_win_tray_set_playing(bool playing);
|
||||
void qbz_win_tray_destroy(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -613,7 +613,12 @@ Column {
|
||||
SettingRow {
|
||||
// Absent while the tray is off — there is no icon to give a
|
||||
// variant to (owner 2026-08-21).
|
||||
visible: root.doc.trayEnable === true
|
||||
//
|
||||
// And absent on Windows, where the notification area draws the
|
||||
// executable's own icon: Windows owns light/dark there and the tray
|
||||
// has no theme arm, so every choice here would render, persist and
|
||||
// change nothing.
|
||||
visible: root.doc.trayEnable === true && QbzShell.isWindows !== true
|
||||
label: QbzSession.tr("Tray icon variant", QbzSession.trRev)
|
||||
description: QbzSession.tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo", QbzSession.trRev)
|
||||
QbzSelect {
|
||||
|
||||
@@ -395,6 +395,9 @@ mod macos_chrome;
|
||||
// Ungated: the C++ behind it is portable Qt, so every platform
|
||||
// type-checks the seam even though only Windows uses the handle.
|
||||
mod win_shell;
|
||||
// Windows tray. The file carries its own `#![cfg(target_os = "windows")]`,
|
||||
// matching how tray_macos/tray_linux are declared.
|
||||
mod tray_windows;
|
||||
// MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04
|
||||
// after smoking the tray: "no aparece por ejemplo en el widget de now playing
|
||||
// de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see
|
||||
|
||||
@@ -2279,9 +2279,12 @@ pub async fn publish_snapshot() {
|
||||
.unwrap_or(false),
|
||||
is_macos: cfg!(target_os = "macos"),
|
||||
is_windows: cfg!(target_os = "windows"),
|
||||
// Windows joins `tray_supported` when plan A-2 T5 lands; that
|
||||
// plan points at this line.
|
||||
tray_supported: cfg!(any(target_os = "linux", target_os = "macos")),
|
||||
// Windows joined with A-2 T5 (Shell_NotifyIconW).
|
||||
tray_supported: cfg!(any(
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
)),
|
||||
// NOT Windows yet, even though the toast arm is implemented.
|
||||
// An unpackaged desktop app only gets toasts when Windows can
|
||||
// RESOLVE its AppUserModelID -- which needs a Start-menu shortcut
|
||||
|
||||
@@ -128,6 +128,11 @@ pub(crate) struct TrayHandle {
|
||||
linux: Option<crate::tray_linux::LinuxTrayHandle>,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos: bool,
|
||||
// Same shape as the macOS flag and for the same reason: the icon lives in
|
||||
// C++ process-global state, so this only records that a tray EXISTS. It is
|
||||
// what `handle().is_some()` -- and therefore close-to-tray -- consults.
|
||||
#[cfg(target_os = "windows")]
|
||||
windows: bool,
|
||||
}
|
||||
|
||||
impl TrayHandle {
|
||||
@@ -137,6 +142,29 @@ impl TrayHandle {
|
||||
h.set_track(title, artist, album);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.windows {
|
||||
// The tray's hover text is the Windows analogue of the Linux
|
||||
// menu header: title on the first line, artist/album below.
|
||||
//
|
||||
// Through the GUI hop, exactly as the macOS theme arm below. This
|
||||
// publisher runs on whatever thread playback pushed from -- the
|
||||
// 1 Hz poll lives in `crate::spawn` -- while the Windows tray keeps
|
||||
// process-global C++ state and talks to a window owned by the GUI
|
||||
// thread. Calling straight through was a data race, not merely a
|
||||
// documentation mismatch. Linux needs no hop because ksni's handle
|
||||
// is a thread-safe channel.
|
||||
let desc = match (artist.is_empty(), album.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(true, false) => album.clone(),
|
||||
(false, true) => artist.clone(),
|
||||
(false, false) => format!("{artist} - {album}"),
|
||||
};
|
||||
crate::tray_bridge::ui(move |_b| {
|
||||
crate::tray_windows::set_tooltip(&title, &desc);
|
||||
});
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (title, artist, album);
|
||||
}
|
||||
@@ -146,6 +174,10 @@ impl TrayHandle {
|
||||
if let Some(h) = &self.linux {
|
||||
h.clear_track();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.windows {
|
||||
crate::tray_bridge::ui(|_b| crate::tray_windows::set_tooltip("QBZ", ""));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_playing(&self, is_playing: bool) {
|
||||
@@ -154,6 +186,11 @@ impl TrayHandle {
|
||||
h.set_playing(is_playing);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.windows {
|
||||
crate::tray_bridge::ui(move |_b| crate::tray_windows::set_playing(is_playing));
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = is_playing;
|
||||
}
|
||||
@@ -170,6 +207,10 @@ impl TrayHandle {
|
||||
crate::tray_bridge::ui(move |_b| crate::tray_macos::set_icon_theme(&theme));
|
||||
return;
|
||||
}
|
||||
// Windows has no arm on purpose: the notification area draws the
|
||||
// executable's own RT_GROUP_ICON and Windows owns light/dark itself,
|
||||
// so there is nothing for a theme choice to change. The settings row
|
||||
// stays hidden there rather than offering a control that does nothing.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let _ = theme;
|
||||
}
|
||||
@@ -373,7 +414,28 @@ pub(crate) fn init(theme_override: String, enabled: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
// 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.
|
||||
#[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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = theme_override;
|
||||
log::info!("[tray] no tray backend on this platform");
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Windows notification-area tray, backed by `cxx/win_tray.cpp`.
|
||||
//!
|
||||
//! The sibling of `tray_linux.rs` (StatusNotifierItem) and `tray_macos.rs`
|
||||
//! (NSStatusItem). All three drive the same `tray_qt` dispatchers and, since
|
||||
//! this one reuses their five `qbz_i18n` keys, present the same menu.
|
||||
//!
|
||||
//! GUI THREAD ONLY. The icon owns a hidden window with a message loop, so a
|
||||
//! call from tokio would post to a thread that never pumps.
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
unsafe extern "C" {
|
||||
fn qbz_win_tray_set_labels(
|
||||
play_pause: *const u16,
|
||||
next: *const u16,
|
||||
previous: *const u16,
|
||||
show_hide: *const u16,
|
||||
quit: *const u16,
|
||||
);
|
||||
fn qbz_win_tray_set_callbacks(
|
||||
on_left_click: extern "C" fn(),
|
||||
on_play_pause: extern "C" fn(),
|
||||
on_next: extern "C" fn(),
|
||||
on_previous: extern "C" fn(),
|
||||
on_quit: extern "C" fn(),
|
||||
);
|
||||
fn qbz_win_tray_create(tooltip: *const u16) -> bool;
|
||||
fn qbz_win_tray_set_tooltip(tooltip: *const u16);
|
||||
fn qbz_win_tray_set_playing(playing: bool);
|
||||
fn qbz_win_tray_destroy();
|
||||
}
|
||||
|
||||
/// NUL-terminated UTF-16, the only string shape the Win32 `W` APIs take.
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Truncate to at most `max_units` UTF-16 CODE UNITS, never mid-character.
|
||||
///
|
||||
/// The Win32 buffers are sized in `wchar_t`, i.e. UTF-16 units, and an emoji
|
||||
/// or any astral character costs two of them. Bounding by `chars()` counts
|
||||
/// scalar values instead, so a 120-char string can be 240 units; `wcsncpy_s`
|
||||
/// with `_TRUNCATE` then cuts at the unit boundary and can keep a HIGH
|
||||
/// SURROGATE with its partner gone -- malformed UTF-16 handed to the shell.
|
||||
///
|
||||
/// Stepping whole `char`s and paying `len_utf16()` for each cannot split a
|
||||
/// pair, because a pair is one `char` here.
|
||||
fn truncate_utf16(s: &str, max_units: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut units = 0usize;
|
||||
for ch in s.chars() {
|
||||
let n = ch.len_utf16();
|
||||
if units + n > max_units {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
units += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// The C ABI callbacks. Each is the same dispatcher the Linux and macOS menus
|
||||
// call, so a tray action behaves identically on all three.
|
||||
extern "C" fn on_left_click() {
|
||||
crate::tray_qt::present();
|
||||
}
|
||||
extern "C" fn on_play_pause() {
|
||||
crate::tray_qt::dispatch_play_pause();
|
||||
}
|
||||
extern "C" fn on_next() {
|
||||
crate::tray_qt::dispatch_next();
|
||||
}
|
||||
extern "C" fn on_previous() {
|
||||
crate::tray_qt::dispatch_previous();
|
||||
}
|
||||
extern "C" fn on_quit() {
|
||||
crate::tray_qt::quit();
|
||||
}
|
||||
|
||||
/// Create the icon. `false` means no tray exists, and the caller MUST leave
|
||||
/// the handle empty when it does: `should_hide_on_close` consults that handle,
|
||||
/// so pretending a failed tray succeeded makes close-to-tray hide the window
|
||||
/// with nothing left to bring it back.
|
||||
pub(crate) fn create() -> bool {
|
||||
// Labels first -- the C side copies them, and the menu is built on demand
|
||||
// from whatever it last received.
|
||||
// The C side holds each label in a 64-unit buffer, so 63 plus the
|
||||
// terminator. Truncated HERE, where char boundaries are known, rather than
|
||||
// by `wcsncpy_s` in C, which counts units and would happily halve a
|
||||
// surrogate pair.
|
||||
const LABEL_UNITS: usize = 63;
|
||||
let play_pause = wide(&truncate_utf16(&qbz_i18n::t("Play/Pause"), LABEL_UNITS));
|
||||
let next = wide(&truncate_utf16(&qbz_i18n::t("Next Track"), LABEL_UNITS));
|
||||
let previous = wide(&truncate_utf16(&qbz_i18n::t("Previous Track"), LABEL_UNITS));
|
||||
let show_hide = wide(&truncate_utf16(&qbz_i18n::t("Show/Hide Window"), LABEL_UNITS));
|
||||
let quit = wide(&truncate_utf16(&qbz_i18n::t("Quit QBZ"), LABEL_UNITS));
|
||||
|
||||
// SAFETY: five NUL-terminated UTF-16 buffers, alive across the call, which
|
||||
// copies them into its own storage.
|
||||
unsafe {
|
||||
qbz_win_tray_set_labels(
|
||||
play_pause.as_ptr(),
|
||||
next.as_ptr(),
|
||||
previous.as_ptr(),
|
||||
show_hide.as_ptr(),
|
||||
quit.as_ptr(),
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: all five are `extern "C"` fns with no captured state, valid for
|
||||
// the life of the process.
|
||||
unsafe {
|
||||
qbz_win_tray_set_callbacks(on_left_click, on_play_pause, on_next, on_previous, on_quit);
|
||||
}
|
||||
|
||||
let tooltip = wide("QBZ");
|
||||
// SAFETY: NUL-terminated and alive across the call.
|
||||
unsafe { qbz_win_tray_create(tooltip.as_ptr()) }
|
||||
}
|
||||
|
||||
/// Hover text, bounded to what `szTip` holds: 128 UTF-16 units including the
|
||||
/// terminator.
|
||||
pub(crate) fn set_tooltip(title: &str, desc: &str) {
|
||||
let joined = if desc.is_empty() {
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("{title}\n{desc}")
|
||||
};
|
||||
// szTip is 128 units INCLUDING the terminator. Track titles are full of
|
||||
// non-ASCII and occasionally astral characters, which is exactly where a
|
||||
// unit-counting truncation goes wrong.
|
||||
let s = truncate_utf16(&joined, 127);
|
||||
let w = wide(&s);
|
||||
// SAFETY: NUL-terminated and alive across the call.
|
||||
unsafe { qbz_win_tray_set_tooltip(w.as_ptr()) }
|
||||
}
|
||||
|
||||
pub(crate) fn set_playing(playing: bool) {
|
||||
// SAFETY: a plain bool by value.
|
||||
unsafe { qbz_win_tray_set_playing(playing) }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired when a Windows quit path needs to remove the icon
|
||||
pub(crate) fn destroy() {
|
||||
// SAFETY: no arguments; idempotent on the C side.
|
||||
unsafe { qbz_win_tray_destroy() }
|
||||
}
|
||||
Reference in new issue
Block a user