merge: integrate Windows port from PR #725

cx
This commit is contained in:
vicrodh committed 2026-08-28 04:18:06 -06:00
commit 912638c387
93 files changed
+6794 -261

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
// Windows shell helpers with no Q_OBJECT of their own.
//
// Compiled on EVERY platform on purpose: the bodies are portable Qt and only
// the caller's use of the WId is Windows-specific. Keeping it unconditional
// means the file is type-checked by the Linux and macOS builds too, which is
// where a signature drift would otherwise go unnoticed until a Windows CI run.
#include <QtCore/QObject>
#include <QtGui/QGuiApplication>
#include <QtGui/QSessionManager>
#include <QtGui/QWindow>
#ifdef _WIN32
#include <QtCore/QAbstractNativeEventFilter>
#include <QtCore/QByteArray>
#include <QtCore/QCoreApplication>
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
// The top-level window's native handle, or nullptr before one is shown.
//
// MUST NOT be called before exec(): QWindow::winId() CREATES the platform
// window as a side effect, so an early call would hand back a handle for a
// window Qt has not finished setting up, and the real one would then get a
// different HWND. Callers hop to the GUI thread first; winId() is not
// thread-safe.
extern "C" void *qbz_main_window_hwnd()
{
const auto windows = QGuiApplication::topLevelWindows();
for (QWindow *w : windows)
if (w->isVisible() && w->type() == Qt::Window)
return reinterpret_cast<void *>(w->winId());
return nullptr;
}
#ifdef _WIN32
namespace {
// Give the custom-chrome window a real hit-test back.
//
// MEASURED, not assumed. With `Qt::CustomizeWindowHint` -- which is the only
// way to stop Qt painting its own minimise/maximise/close cluster on top of
// QBZ's header -- Qt answers WM_NCHITTEST with HTNOWHERE for the ENTIRE
// window: centre, header, player bar and sidebar all returned 0. Windows
// delivers no mouse input to HTNOWHERE, so the app looked frozen while it was
// running perfectly and the tray menu still drove playback.
//
// Without that hint Qt hit-tests correctly but populates the default title
// hints, and its customized-title path draws the buttons over our header. No
// combination of public flags gives all three of: no drawn buttons, working
// clicks, and the WS_THICKFRAME that Windows tiling needs.
//
// So the flags keep CustomizeWindowHint and this filter answers the hit test
// the way an ordinary sizable, caption-less window would: DefWindowProcW
// returns the eight edge codes near the frame and HTCLIENT everywhere else.
// Nothing here invents geometry -- it defers to the same code every other
// borderless-resizable window uses.
//
// Dragging is unaffected and stays with QML's `startSystemMove()`: with no
// caption there is no HTCAPTION band to inherit, which is the arrangement the
// header was already written for.
class QbzNcHitTestFilter : public QAbstractNativeEventFilter
{
public:
bool nativeEventFilter(const QByteArray &type, void *message, qintptr *result) override
{
if (type != QByteArrayLiteral("windows_generic_MSG"))
return false;
MSG *msg = static_cast<MSG *>(message);
if (!msg || msg->message != WM_NCHITTEST)
return false;
// NARROW ON PURPOSE: only a window that is sizable AND caption-less,
// which is exactly the custom-chrome main window. Menus, tooltips and
// popups have no WS_THICKFRAME; a window showing the system title bar
// has WS_CAPTION and must keep Qt's own answer. Getting this wrong
// would break hit-testing everywhere instead of fixing it in one
// place.
const LONG_PTR style = GetWindowLongPtrW(msg->hwnd, GWL_STYLE);
if ((style & WS_THICKFRAME) == 0 || (style & WS_CAPTION) == WS_CAPTION)
return false;
*result = static_cast<qintptr>(
DefWindowProcW(msg->hwnd, WM_NCHITTEST, msg->wParam, msg->lParam));
return true;
}
};
QbzNcHitTestFilter *g_hit_filter = nullptr;
} // namespace
#endif // _WIN32
// Install the hit-test filter. Idempotent; a no-op off Windows.
extern "C" void qbz_install_hittest_filter()
{
#ifdef _WIN32
if (g_hit_filter)
return;
g_hit_filter = new QbzNcHitTestFilter;
QCoreApplication::instance()->installNativeEventFilter(g_hit_filter);
#endif
}
// WM_QUERYENDSESSION -> QGuiApplication::commitDataRequest, via Qt's Windows
// session manager. It fires BEFORE Windows decides whether to proceed with the
// logoff and Qt blocks until the handler returns, which makes it the one safe
// place to persist synchronously: anything deferred to a queued connection or
// another thread races the process being killed.
extern "C" void qbz_install_commit_data_handler(void (*cb)())
{
#ifndef QT_NO_SESSIONMANAGER
// Once. Qt::UniqueConnection does NOT deduplicate lambdas -- each connect
// makes an independent connection -- so a second install would run the
// session persist twice on every logoff.
static bool installed = false;
if (installed)
return;
installed = true;
QObject::connect(qApp, &QGuiApplication::commitDataRequest, qApp,
[cb](QSessionManager &) { cb(); });
#else
// A Qt built without the session manager has no commitDataRequest at all,
// and this file compiles on every platform.
(void)cb;
#endif
}
+356
View File
@@ -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
+40
View File
@@ -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