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

+34 -2
View File
@@ -30,6 +30,10 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
reqwest = { workspace = true }
urlencoding = "2"
# Deep links are validated by a real parse before they are acted on: a
# launcher can split a URL across argv, and a bare scheme is not a URL.
# Already in the workspace lock via qbz-models.
url = "2"
open = "5"
dirs = "5"
@@ -133,8 +137,9 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
# is the Wayland-correct one and needs no extra system libs.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS uses the
# native implementation in src/tray_macos.rs; Windows currently has no tray.
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS and
# Windows use their native implementations in src/tray_macos.rs and
# src/tray_windows.rs.
#
# default-features = false: ksni's default is "tokio", which flips the shared
# zbus 5 executor to tokio graph-wide. We only use the blocking API on
@@ -157,6 +162,12 @@ cxx-qt-build = "0.7"
qt-build-utils = "0.7"
cc = "1"
[target.'cfg(windows)'.build-dependencies]
# Icon + VERSIONINFO + the longPathAware manifest, embedded as PE resources.
# winresource, not winres: winres has been unmaintained since 2021 and this
# is its maintained fork.
winresource = "0.1"
# macOS only: the Dock-icon activation policy behind the "Hide Dock icon when
# closed to menu bar" setting (Spotify behaviour — close puts the app in the
# menu bar and drops the Dock icon; minimize still goes to the Dock).
@@ -168,6 +179,27 @@ cc = "1"
# ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is
# behind cfg, so `cargo build` cannot see it. First real check is a macOS
# build — the Mac mini (192.168.0.69) is where that happens.
[target.'cfg(target_os = "windows")'.dependencies]
# SetCurrentProcessExplicitAppUserModelID: the identity Windows groups the
# taskbar button under and matches a toast's app_id against. 0.61 is already
# in the workspace lock.
windows-sys = { version = "0.61", features = [
"Win32_UI_Shell",
# Single instance: CreateMutexW arbitrates, a named pipe carries the
# handoff, and both need the error codes to tell "already there" from
# "failed".
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Pipes",
# ProcessIdToSessionId: the pipe name is qualified by session, because
# pipe names are machine-global while the mutex is per-session.
"Win32_System_RemoteDesktop",
"Win32_Storage_FileSystem",
# RegGetValueW: is this install's AppUserModelID actually registered?
# Without that, Windows silently drops every toast.
"Win32_System_Registry",
] }
[target.'cfg(target_os = "macos")'.dependencies]
# DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`,
# `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and
+94 -2
View File
@@ -5,6 +5,12 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule};
/// Collect every file under `dir` (recursive), crate-root-relative — the
/// baked icon variants (qml/assets/icons/<tint>/<name>.svg) are too many to
/// list by hand.
///
/// The strings become qrc ALIASES verbatim (qt-build-utils writes
/// `<file alias="{path}">` from `Path::display()`), and QML asks with `/`
/// (`QbzIcon.qml:231`, `FontPreload.qml:40-46`). On Windows `read_dir` joins
/// with `\`, so normalise here or every asset fails with
/// "QQuickImage: Cannot open".
fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
for entry in
std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
@@ -13,11 +19,46 @@ fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
if path.is_dir() {
collect_qrc_files(&path, out);
} else {
out.push(path.to_string_lossy().into_owned());
out.push(qrc_alias(&path));
}
}
}
/// A qrc alias is always `/`-separated, whatever the host separator.
///
/// Rewritten only when the BUILD HOST is Windows. On Unix a backslash is a
/// LEGAL filename character, so rewriting it there would quietly point the
/// alias at a different file (or at none). `cfg!` in a build script reads the
/// host, which is exactly the separator `read_dir` just produced.
fn qrc_alias(path: &Path) -> String {
let raw = path.to_string_lossy();
if cfg!(windows) {
raw.replace('\\', "/")
} else {
raw.into_owned()
}
}
/// MSVC reports `__cplusplus` as `199711L` no matter what `/std:` says, unless
/// `/Zc:__cplusplus` is passed. Qt refuses that value outright —
/// `qcompilerdetection.h:1317` raises "Qt requires a C++17 compiler, and a
/// suitable value for __cplusplus" on EVERY translation unit that includes a
/// Qt header, ours and moc's alike, so the first Windows build cannot get past
/// the Qt headers. And without `/permissive-` the Qt 6.9 headers do not parse
/// under MSVC's default non-conforming mode: measured here, `qtmochelpers.h:262`
/// C2065 'result', `qcomparehelpers.h:1348` C2968 recursive alias, and C2737 on
/// every moc `staticMetaObject`. Both flags are what Qt's own CMake passes;
/// neither cc-rs nor cxx-qt-build adds either.
///
/// The build script runs on the HOST, so ask cargo for the TARGET env rather
/// than `cfg!()` — identical for a native build, not for a cross one.
fn apply_msvc_qt_flags(cc: &mut cc::Build) {
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
cc.flag("/Zc:__cplusplus");
cc.flag("/permissive-");
}
}
/// Every RHI variant a Qt Quick shader should carry.
///
/// `qsb --qt6` is only `100 es,120,150` + HLSL 50 + MSL 12. This is that plus
@@ -82,9 +123,23 @@ fn find_qsb() -> Option<std::path::PathBuf> {
return Some(p);
}
}
// Windows: `qsb.exe`, and `Path::is_file()` does NOT append `.exe` (Rust's
// std only does that for a full path handed to `Command`), so probe both.
let names: &[&str] = if cfg!(windows) { &["qsb.exe", "qsb"] } else { &["qsb"] };
if let Ok(path) = std::env::var("PATH") {
for dir in std::env::split_paths(&path) {
let p = dir.join("qsb");
for name in names {
let p = dir.join(name);
if p.is_file() {
return Some(p);
}
}
}
}
// aqt / install-qt-action export QT_ROOT_DIR (e.g. F:\Qt\6.9.3\msvc2022_64).
if let Ok(root) = std::env::var("QT_ROOT_DIR") {
for name in names {
let p = std::path::Path::new(&root).join("bin").join(name);
if p.is_file() {
return Some(p);
}
@@ -296,6 +351,7 @@ fn build_rhi_items() {
let mut cc = cc::Build::new();
cc.cpp(true).std("c++17").pic(true).include("cxx"); // the moc outputs do `#include "<name>.h"`
apply_msvc_qt_flags(&mut cc);
for item in [
"linebed_item",
"plasma_item",
@@ -326,6 +382,13 @@ fn build_rhi_items() {
// when hybrid-GPU implicit layers are active.
println!("cargo:rerun-if-changed=cxx/qt_vulkan_probe.cpp");
cc.file("cxx/qt_vulkan_probe.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);
}
@@ -607,6 +670,7 @@ fn main() {
// QbzAbout's two documents.
"qml/shell/AboutModal.qml",
"qml/shell/WhatsNewModal.qml",
"qml/shell/WindowsDisclaimerModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
// views/playlistmanager/. B2 shipped the root overlay + the
@@ -860,5 +924,33 @@ fn main() {
qrc_files: &qrc_refs,
..Default::default()
})
.cc_builder(apply_msvc_qt_flags)
.build();
embed_windows_resources();
}
/// Icon, VERSIONINFO and the manifest, as PE resources.
///
/// The manifest declares `longPathAware` and NOTHING ELSE. Never add
/// `dpiAware`/`dpiAwareness`: Qt 6 sets PerMonitorV2 itself at startup, a
/// manifest declaration is immutable, and the two together either log a
/// permanent COM error 0x5 or silently degrade scaling.
///
/// `build.rs` runs with `crates/qbz-qt` as its working directory, hence
/// `../../packaging`.
#[cfg(windows)]
fn embed_windows_resources() {
println!("cargo:rerun-if-changed=../../packaging/icons/icon.ico");
println!("cargo:rerun-if-changed=../../packaging/windows/qbz.exe.manifest");
let mut res = winresource::WindowsResource::new();
res.set_icon("../../packaging/icons/icon.ico");
res.set_manifest_file("../../packaging/windows/qbz.exe.manifest");
res.set("ProductName", "QBZ");
res.set("FileDescription", "QBZ — Qobuz hi-res player");
res.set("LegalCopyright", "MIT — github.com/vicrodh/qbz");
res.compile().expect("windows resource");
}
#[cfg(not(windows))]
fn embed_windows_resources() {}
+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
+60 -2
View File
@@ -140,11 +140,66 @@ ApplicationWindow {
// the frame, and the reference never draws its own there
// ("Linux only — macOS keeps the native traffic lights",
// WindowControls.slint:1-2).
// WINDOWS takes the macOS shape, not the Linux one, and the difference is
// the whole feature (W27).
//
// `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the
// shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both
// stop, along with the DWM drop shadow and the Win11 rounded corners. So
// "hide title bar" was not making the app refuse the shortcuts -- it had
// stopped being a window the shell could tile.
//
// NOT restored, and it cannot be from flags alone: the Windows 11 Snap
// Layouts flyout that appears when hovering a maximise button. Windows asks
// for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise
// button is a QML item inside the client area, so it answers HTCLIENT.
// Wiring it would mean reporting HTMAXBUTTON over that one rect from the
// native filter -- deliberate future work, not something this arm does.
//
// Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0,
// DwmExtendFrameIntoClientArea, the eight resize edges and
// safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the
// macOS arm uses. `CustomizeWindowHint` with no button hints then
// suppresses Qt's own painted title cluster
// (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS
// WS_CAPTION, so the shell still treats it as tileable.
//
// It also provides no HTCAPTION drag band of its own (:3332-3338: with
// both isDefaultTitleBar and isCustomized false the whole title block is
// skipped), which is why HeaderBar's QML `startSystemMove()` stays: it
// issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips
// stay enabled too and do not fight the native edges -- Qt answers
// WM_NCHITTEST first, so a press on the border never reaches QML.
flags: QbzShell.systemTitleBar
? Qt.Window
: (QbzShell.isMacos
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
: (Qt.Window | Qt.FramelessWindowHint))
: (QbzShell.isWindows
// Both halves were MEASURED on the box, and neither is
// optional:
//
// WITHOUT CustomizeWindowHint, Qt populates the default title
// hints and its customized-title path DRAWS minimise/maximise/
// close over the QBZ header.
//
// WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every
// pixel -- centre, header, player bar and sidebar all returned
// 0 -- and Windows sends no mouse input to HTNOWHERE, so the
// whole app becomes unclickable while still running.
//
// No public flag combination gives no-buttons + clicks +
// the WS_THICKFRAME that tiling needs, so the hint stays and
// `win_shell::install_hittest_filter()` answers the hit test
// instead. What this arm must NOT do is drop the hint: that
// trades the buttons back in.
//
// Frameless is not the answer either: it removes
// WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap
// actually key on -- not WS_CAPTION, which this window does
// not have in either arrangement.
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint
| Qt.CustomizeWindowHint)
: (Qt.Window | Qt.FramelessWindowHint)))
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
// flag look broken when it was not.
@@ -369,7 +424,10 @@ ApplicationWindow {
// all stay at their defaults, and nothing would be logged on either
// side. The wizard is the loudest case after QbzBlacklist, because its
// first step ends on "Checking your audio stack…" forever.
QbzDacWizard.boot()
// Linux only: the wizard's row is hidden elsewhere (AudioSettings.qml),
// so booting it would spin up an audio-stack probe nothing can reach.
if (QbzShell.isLinux)
QbzDacWizard.boot()
// Offline Cache Manager. Nothing to seed — the view is unmounted until
// the Settings row opens it — but without this line the manager's very
// first publish is dropped and the view sits on its spinner forever:
@@ -43,7 +43,20 @@ Window {
// clients — there is no such request in the core protocol; it takes a
// compositor policy or a KWin rule, and Qt has nothing to send. The owner
// chose removal over a button that lies.
// WINDOWS ONLY, owner decision D8 (2026-08-27), reversing nothing about
// K1: that ruling removed always-on-top because the owner smoked it on
// real Plasma Wayland and the hint did nothing -- a Wayland client cannot
// raise itself above other clients, there is no such request in the core
// protocol, and Qt had nothing to send. On Windows the same hint IS
// HWND_TOPMOST and does exactly what it says.
//
// So this is a DELIBERATE PER-PLATFORM DIVERGENCE, not a revert: the pin
// exists where the window manager can honour it and stays absent where it
// cannot. Linux and macOS keep the exact flags they had.
property bool pinnedOnTop: false
flags: Qt.Window | Qt.FramelessWindowHint
| ((QbzShell.isWindows && root.pinnedOnTop) ? Qt.WindowStaysOnTopHint : 0)
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
title: "QBZ Mini Player"
@@ -187,7 +187,21 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.exit()
}
// NO PIN. Owner ruling K1 shipped always-on-top as NEW work
// THE PIN, on Windows only (owner decision D8, 2026-08-27).
// `Qt.WindowStaysOnTopHint` is HWND_TOPMOST there and works;
// everything below is why it is absent everywhere else, and
// that reasoning still stands unchanged.
CapBtn {
name: "pin"
visible: QbzShell.isWindows
active: root.hostWindow ? root.hostWindow.pinnedOnTop : false
anchors.verticalCenter: parent.verticalCenter
onClicked: {
if (root.hostWindow)
root.hostWindow.pinnedOnTop = !root.hostWindow.pinnedOnTop
}
}
// NO PIN ELSEWHERE. Owner ruling K1 shipped always-on-top as NEW work
// (the reference promises it in three comments and implements
// it nowhere), and the owner smoked it on real Plasma Wayland
// on 2026-08-04: `Qt.WindowStaysOnTopHint` did not keep the
@@ -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 {
+34 -2
View File
@@ -150,6 +150,13 @@ Column {
}
}
SettingRow {
// ALSA only. fc0d35e29 opened this to WASAPI expecting plan B Task 6
// (DoP over exclusive mode); the spike then measured this DAC refusing
// 176400, which is the DSD64 DoP carrier, and Task 6 lost its target.
// Both non-convert modes are `cfg(target_os = "linux")` in the audio
// thread and answer "DoP playback is Linux-only" with a stream error,
// so on Windows the row could only ever offer two ways to fail. DSD
// still plays there -- converted to PCM, which is the default.
visible: root.doc.backendIsAlsa === true
label: QbzSession.tr("DSD playback", QbzSession.trRev)
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
@@ -174,6 +181,12 @@ Column {
// Opening it resets the wizard and kicks the audio-stack probe off the UI
// thread, so there is nothing to arm here.
SettingRow {
// The generated config is PipeWire/WirePlumber and qbz-dac-wizard-core
// knows no other stack, so off Linux the wizard cannot finish. Hidden,
// not disabled: a disabled row invites a bug report. This is NOT the
// reference's `backend-is-pipewire` gate coming back - within Linux
// every backend still sees it, which is the owner ruling above.
visible: QbzShell.isLinux
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
SettingsButton {
@@ -190,16 +203,35 @@ Column {
// =========================== BIT-PERFECT =============================
GroupHeader { text: QbzSession.tr("BIT-PERFECT", QbzSession.trRev) }
SettingRow {
// Hidden where no backend can honour it: on Windows until WASAPI
// exclusive lands (Phase B publishes backendIsWasapi), and on macOS,
// whose only backend is System default. Linux is unchanged - visible
// always, enabled only on ALSA.
//
// WINDOWS: hidden on purpose, even now that Phase B publishes
// `backendIsWasapi`. The exclusive path is chosen by the BACKEND
// dropdown ("WASAPI Exclusive"), not by this toggle -- the selection
// arm in qbz-player keys on `backend_type` and never reads
// `exclusive_mode`. Showing the toggle would put a control under the
// BIT-PERFECT header that changes nothing, which is worse than no
// control at all when the thing being judged IS bit-perfect. Whether
// Windows should instead mirror the Linux split (backend = transport,
// toggle = hw/plughw) is an open design question for the owner.
visible: QbzShell.isLinux
label: QbzSession.tr("Exclusive mode", QbzSession.trRev)
description: QbzSession.tr("Lock the device so no other app can resample it.", QbzSession.trRev)
rowEnabled: root.doc.backendIsAlsa === true
rowEnabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
QbzToggle {
checked: root.doc.exclusiveMode === true
enabled: root.doc.backendIsAlsa === true
enabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
onToggled: function (v) { QbzBridge.settingsBool("exclusive-mode", v) }
}
}
SettingRow {
// D-Bus org.freedesktop.ReserveDevice1, which is Linux only. On
// Windows exclusive mode IS the reservation (Phase B); on macOS it is
// Hog Mode.
visible: QbzShell.isLinux
label: QbzSession.tr("Reserve DAC while running", QbzSession.trRev)
description: QbzSession.tr("Hold the device reserved so other apps can't grab it.", QbzSession.trRev)
QbzToggle {
+12
View File
@@ -929,4 +929,16 @@ Rectangle {
WhatsNewModal {
anchors.fill: parent
}
// A LOADER, not a static mount. `visible: false` still CONSTRUCTS the
// whole subtree -- panel, Flickable, fourteen Repeater delegates, their
// texts and bindings -- on Linux and macOS, where this modal can never be
// shown. The Loader makes that cost real only where the modal exists.
//
// Last of the three so it lands on top at equal z: it is shown once per
// version at startup and has to be answered.
Loader {
anchors.fill: parent
active: QbzShell.isWindows && QbzShell.windowsDisclaimerOpen === true
sourceComponent: WindowsDisclaimerModal { }
}
}
+14 -2
View File
@@ -72,11 +72,23 @@ Item {
// otherwise. Empty when no detail exists (never a bare "CD: ").
readonly property string qualityTooltip: {
if (QbzPlayer.npQualityDetail === "") return ""
if (!showDelivered) return tierLabel + ": " + QbzPlayer.npQualityDetail
if (!showDelivered)
return tierLabel + ": " + QbzPlayer.npQualityDetail + root.streamModeLine
var t = QbzSession.tr("Source: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityDetail)
+ "\n"
+ QbzSession.tr("Output: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityTrueDetail)
return causeLine === "" ? t : t + "\n" + causeLine
t = causeLine === "" ? t : t + "\n" + causeLine
return t + root.streamModeLine
}
// What the OPENED stream negotiated, appended to the tooltip. The mode LED
// beside it is derived from SETTINGS, so it says what was ASKED FOR; this
// says what the engine actually got. When they disagree, that is the whole
// point. Four technical tokens, so nothing new to translate.
readonly property string streamModeLine: {
var m = QbzPlayer.npBitPerfectMode
if (m === "" || m === "unknown") return ""
return "\nstream: " + m
}
readonly property bool localOutput: !QbzPlayer.npIsRemote && !QbzPlayer.npCastActive
+11 -3
View File
@@ -250,9 +250,17 @@ Item {
}
entries.push(
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
"view": "", "tab": "", "action": "openFolder", "enabled": true },
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true },
"view": "", "tab": "", "action": "openFolder", "enabled": true })
// W16: qbz-disc has no Windows CDDA backend -- `mod unsupported`
// compiles and every call returns NoDrive. Offering the entry would
// only ever produce "no drive found" on a machine that has one. SACD
// stays: that opens an image file, which needs no drive.
if (!QbzShell.isWindows) {
entries.push(
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true })
}
entries.push(
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
"view": "", "tab": "", "action": "openSacd", "enabled": true })
if (QbzLocal.localEphemeralActive) {
+2 -2
View File
@@ -34,11 +34,11 @@
// MSL 1.2 for Metal, SPIR-V always embedded for Vulkan). Regenerate after
// ANY shader-source edit, from crates/qbz-qt:
// for m in bars wave energy; do \
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 \
// -o qml/assets/shaders/spectrum_$m.frag.qsb \
// qml/assets/shaders/spectrum_$m.frag; \
// done
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 -b \
// -o qml/assets/shaders/spectrum.vert.qsb qml/assets/shaders/spectrum.vert
// (macOS: qsb is at $(brew --prefix qt)/bin/qsb.) A .qsb is keyed to the Qt
// Shader Tools version that baked it — rebake when the system Qt moves.
@@ -0,0 +1,216 @@
// The Windows "as-is" disclaimer, shown once per version at startup.
//
// Structure, scrim, shadow, z and the self-gate follow WhatsNewModal.qml,
// which follows LogViewerModal.qml. Colour literals are `Qt.rgba(...)`
// because Slint is #RRGGBBAA and Qt is #AARRGGBB -- the copy that shipped an
// invisible scrim in five modals, fixed 2026-08-14.
//
// ── WHY THE BODY IS NOT TRANSLATED ─────────────────────────────────────────
//
// This is the author's own statement of policy, quoted from the release notes
// ("Windows -- yes, but read this first"). The wording is the point: it says
// what is and is not promised, and in whose voice. The CHROME around it (the
// title, the checkbox, the button) goes through `tr` like everything else, so
// the frame localises and the statement does not get paraphrased by a
// translator into something that promises more or less than it does.
//
// ── WHY THIS IS NOT `Text.MarkdownText` ────────────────────────────────────
//
// Same reason WhatsNewModal gives, minus the parser: the source has exactly
// two constructs, `**bold**` and one link, so the paragraphs below carry
// `<b>` and `<a>` and render as StyledText. No dependency, no subset to
// disagree about.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
anchors.fill: parent
z: 3000
QbzTheme { id: theme }
// The body, verbatim from the release notes. One entry per paragraph.
// `lead: true` renders at the emphasis size -- the two sentences that are
// the actual disclaimer rather than the explanation around it.
readonly property var paragraphs: [
{ lead: false, text: "There is now a Win32 build of QBZ." },
{ lead: false, text: "There is, however, one important distinction:" },
{ lead: true, text: "<b>Windows is not a supported QBZ platform.</b>" },
{ lead: false, text: "The Windows build is provided <b>as-is</b>. Think of it like buying something second-hand: what you see is what you get." },
{ lead: false, text: "I won't promise feature parity, chase Windows-specific regressions, maintain a separate Windows roadmap, or guarantee that something Microsoft changes six months from now won't break it." },
{ lead: false, text: "If it works for you, great. If it doesn't, there is no promise that I'll fix it." },
{ lead: false, text: "And to be completely transparent about why: <b>I hated using Windows while working on this port.</b>" },
{ lead: false, text: "Installing it, configuring the build box, dealing with the environment and especially figuring out its audio setup reminded me why I don't use it in the first place. I have no desire to spend more time than absolutely necessary inside Redmond's OS every time QBZ gets a release." },
{ lead: false, text: "Sorry. Long-time Gentoo Linux user. Some biases are too old to fix." },
{ lead: false, text: "So why ship it?" },
{ lead: false, text: "Because the hard part — getting QBZ running properly on Win32 — is done, and useful code is better in people's hands than sitting in a branch because I personally don't want to maintain another operating system." },
{ lead: false, text: "More importantly, <b>the Windows port is open for adoption</b>." },
{ lead: false, text: "That's essentially what happened with macOS. <a href=\"https://github.com/afonsojramos\">@afonsojramos</a> took ownership of the macOS port and is the reason it grew from an experiment into a properly supported QBZ platform." },
{ lead: false, text: "If someone wants to do the same for Windows, maintain it and eventually remove this rather large disclaimer, get in touch." }
]
property bool remember: false
// Component.onCompleted, NOT onVisibleChanged. `Item.visible` defaults to
// true and the Loader only creates this when it should already be shown,
// so there is no false->true transition to observe: the old handler never
// ran, focus was never taken, Escape never reached the handler below, and
// ordinary hotkeys kept working BEHIND the modal.
Component.onCompleted: keyScope.forceActiveFocus()
// Qt strands activeFocus on an item that is being destroyed, which kills
// AppShell's key dispatcher until the next click. Hand focus back to the
// shell root on the way out -- the same duck-walk QbzConfirmModal uses,
// and for the same measured reason.
Component.onDestruction: root._restoreShellFocus()
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
FocusScope {
id: keyScope
anchors.fill: parent
// Escape closes WITHOUT remembering: dismissing a disclaimer by
// reflex must never be read as having accepted it for good.
Keys.onEscapePressed: QbzShell.dismissWindowsDisclaimer(false)
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.75)
// No click-through close. Unlike the other modals this one is the
// first thing a new user sees and says what is not promised; a stray
// click on the scrim should not dismiss it.
MouseArea { anchors.fill: parent }
}
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: Qt.rgba(0, 0, 0, 0.5)
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(root.width - 80, 720)
height: Math.min(root.height - 80, 640)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Item {
anchors.fill: parent
anchors.margins: 24
Text {
id: headerText
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: QbzSession.tr("Windows — yes, but read this first", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 18
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Flickable {
id: scroller
anchors.top: headerText.bottom
anchors.topMargin: 16
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: footerRow.top
anchors.bottomMargin: 16
clip: true
contentWidth: width
contentHeight: body.height
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: scroller.width
spacing: 12
Repeater {
model: root.paragraphs
Text {
width: parent.width
text: modelData.text
// StyledText, not RichText: it is the cheap
// subset and it is all these paragraphs use.
textFormat: Text.StyledText
wrapMode: Text.WordWrap
color: modelData.lead ? theme.textPrimary : theme.textSecondary
font.pixelSize: modelData.lead ? 15 : 13
linkColor: theme.accent
onLinkActivated: function (url) { Qt.openUrlExternally(url) }
}
}
}
}
Item {
id: footerRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 32
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzCheckbox {
id: rememberBox
anchors.verticalCenter: parent.verticalCenter
checked: root.remember
// `toggled()` carries NO argument here (see
// QbzCheckbox.qml): the control reports that it was
// hit, and the owner of the state flips it.
onToggled: root.remember = !root.remember
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Don't show this again", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
MouseArea {
anchors.fill: parent
onClicked: root.remember = !root.remember
}
}
}
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
// `label`, not `text` -- this control is not a Qt Button.
label: QbzSession.tr("I understand", QbzSession.trRev)
btnHeight: 32
onClicked: QbzShell.dismissWindowsDisclaimer(root.remember)
}
}
}
}
}
@@ -242,14 +242,19 @@ Item {
CardMenu {
id: openMenu
menuWidth: 220
// W16: the audio-CD row is dropped on Windows -- qbz-disc's CDDA
// backend is `mod unsupported` there and answers NoDrive to
// everything. SACD is an image file, so it stays on every platform.
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" },
"icon": "folder-open", "action": "folder" }
].concat(QbzShell.isWindows ? [] : [
{ "label": QbzSession.tr("Open audio CD", QbzSession.trRev),
"icon": "disc", "action": "cd" },
"icon": "disc", "action": "cd" }
]).concat([
{ "label": QbzSession.tr("Open SACD image…", QbzSession.trRev),
"icon": "disc-3", "action": "sacd" }
]
])
onPicked: function (action) {
if (action === "folder") QbzLocal.ephemeralOpen()
else if (action === "cd") QbzLocal.ephemeralOpenCd()
+4 -1
View File
@@ -352,7 +352,10 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
// NOT trim_start_matches: cached_path returns a REAL file:// URL,
// so on Windows that leaves the `/` before the drive letter
// (`/C:/Users/...`), which opens nothing.
let path = qbz_models::fs_url::local_path(&path);
// One decode, two products: the flat tint (the FALLBACK arm) and the
// blurred atmosphere Slint actually renders behind the header.
let p2 = path.clone();
+4 -1
View File
@@ -359,7 +359,10 @@ pub fn update_for_artwork(artwork_url: &str) {
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
// NOT trim_start_matches: cached_path returns a REAL file:// URL,
// so on Windows that leaves the `/` before the drive letter
// (`/C:/Users/...`), which opens nothing.
let path = qbz_models::fs_url::local_path(&path);
let triad = tokio::task::spawn_blocking(move || {
let bytes = std::fs::read(&path).ok()?;
let img = image::load_from_memory(&bytes).ok()?;
+7 -20
View File
@@ -219,7 +219,7 @@ pub fn classify(url: &str) -> ArtUrl {
ArtUrl::Plex(fetch)
};
}
if url.starts_with('/') {
if qbz_models::fs_url::is_local_abs_path(url) {
return ArtUrl::LocalFile(url.to_string());
}
ArtUrl::Empty
@@ -240,27 +240,11 @@ pub fn classify(url: &str) -> ArtUrl {
/// result (a tint decode, a copy) for exactly the files the escaping exists to
/// support.
pub fn local_path(url: &str) -> String {
let raw = url.strip_prefix("file://").unwrap_or(url);
raw.replace("%23", "#")
.replace("%3F", "?")
.replace("%25", "%")
qbz_models::fs_url::local_path(url)
}
pub fn file_url(path: &str) -> String {
if path.starts_with("file://") {
return path.to_string();
}
let mut out = String::with_capacity(path.len() + 8);
out.push_str("file://");
for ch in path.chars() {
match ch {
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
qbz_models::fs_url::file_url(path)
}
// ---------------------------------------------------------------------------
@@ -984,7 +968,10 @@ fn scaled_output_path(path: &str, w: u32, h: u32) -> Option<(String, PathBuf)> {
if w == 0 || h == 0 {
return None;
}
let src = path.strip_prefix("file://").unwrap_or(path).to_string();
// NOT strip_prefix: leaves the `/` before a Windows drive letter, and the
// three percent-escapes, in a string used BOTH as the cache key and as
// the path to open.
let src = qbz_models::fs_url::local_path(path);
let dir = dirs::cache_dir()?.join("qbz").join("images").join("scaled");
let mut hasher = std::collections::hash_map::DefaultHasher::new();
+6 -1
View File
@@ -126,7 +126,12 @@ fn cache_dir() -> Option<PathBuf> {
/// Blocking: decodes and resizes. Call it from `spawn_blocking`, never on the
/// Qt thread.
pub fn for_cover_blocking(cover_path: &str) -> Option<String> {
let src_path = cover_path.strip_prefix("file://").unwrap_or(cover_path);
// NOT strip_prefix: a real file:// URL leaves the `/` before the drive on
// Windows (`/C:/Users/...`), so image::open finds nothing and the header
// atmosphere - and therefore the whole album/artist header gradient -
// silently never appears.
let src_path = qbz_models::fs_url::local_path(cover_path);
let src_path = src_path.as_str();
let dir = cache_dir()?;
// Key on the cover path, like the artwork cache does.
+102 -1
View File
@@ -16,8 +16,80 @@ pub(crate) fn is_qobuz_link(arg: &str) -> bool {
|| arg.starts_with("http://open.qobuz.com/")
}
/// `qbz://` is OUR scheme, registered by the Windows MSI beside
/// `qobuzapp://`. It is rewritten rather than taught downstream: the resolver
/// (`qbz-music-link`) knows `qobuzapp://` and the Qobuz web shapes and nothing
/// else, so an accepted `qbz://` would travel the whole way and then be
/// classified "unsupported or invalid music link" -- which is exactly what a
/// live test of the forwarding path produced. The two carry the same
/// `album/track/artist` shape, so a prefix swap is the whole translation.
///
/// `is_qobuz_link` deliberately still answers false for it: that predicate
/// describes what the LAUNCHER files advertise, and its test pins that.
fn rewrite_own_scheme(arg: &str) -> Option<String> {
arg.strip_prefix("qbz://")
.map(|rest| format!("qobuzapp://{rest}"))
}
/// The first argument that is a link we can actually act on.
///
/// Keeps SCANNING past one it cannot use rather than giving up: a launcher can
/// hand over several arguments and the first match is not necessarily the
/// usable one.
fn select_link(args: &[String]) -> Option<String> {
args.iter().find(|arg| is_qobuz_link(arg)).cloned()
for arg in args {
let candidate = if is_qobuz_link(arg) {
arg.clone()
} else if let Some(rewritten) = rewrite_own_scheme(arg) {
rewritten
} else {
continue;
};
// WINDOWS ONLY. ShellExecute hands the app whatever the registered
// `shell\open\command` produced, and an unencoded space in the link
// splits it across argv -- so a fragment can carry the scheme and
// nothing else. Acting on that navigates nowhere and reads as the deep
// link silently failing.
//
// Not applied elsewhere: Linux and macOS have accepted whatever
// matched the prefix since this shipped, and tightening that from a
// Windows port would be a behaviour change on platforms that never
// asked for it.
#[cfg(target_os = "windows")]
if url::Url::parse(&candidate).is_err() {
log::warn!(
"[qbz-qt] deep link: ignoring an unparseable argument ({})",
candidate.split('?').next().unwrap_or(&candidate)
);
continue;
}
return Some(candidate);
}
None
}
/// Is this something the forwarding path may act on?
///
/// The single-instance server feeds it whatever arrived on the pipe, which is
/// not argv and is not ours.
pub(crate) fn is_actionable(url: &str) -> bool {
if !is_qobuz_link(url) && !url.starts_with("qbz://") {
return false;
}
// A parse is NOT enough, which a test caught: `Url::parse("qbz://")`
// SUCCEEDS -- a valid scheme with an empty everything -- so a bare scheme
// would have passed the gate and reached the resolver as a navigation
// request for nothing.
//
// Every shape this accepts carries its target in the AUTHORITY:
// `qobuzapp://album/1` and `qbz://album/1` put "album" there, and the web
// URLs put the Qobuz host there. An empty one is not a link.
match url::Url::parse(url) {
Ok(u) => u.host_str().is_some_and(|h| !h.is_empty()),
Err(_) => false,
}
}
/// Capture argv before the single-instance decision so a duplicate process
@@ -81,6 +153,35 @@ mod tests {
assert!(!is_qobuz_link("qbz://album/a"));
}
#[test]
fn our_own_scheme_is_rewritten_not_taught() {
// The resolver knows qobuzapp:// and the Qobuz web shapes. A qbz://
// URL that reached it came back "unsupported or invalid music link",
// so the translation happens here.
assert_eq!(
rewrite_own_scheme("qbz://album/12345").as_deref(),
Some("qobuzapp://album/12345")
);
assert_eq!(rewrite_own_scheme("qobuzapp://album/1"), None);
assert_eq!(rewrite_own_scheme("https://open.qobuz.com/album/1"), None);
}
#[test]
fn a_rewritten_link_is_selected() {
let args = vec!["--flag".to_string(), "qbz://track/9".to_string()];
assert_eq!(select_link(&args).as_deref(), Some("qobuzapp://track/9"));
}
#[test]
fn the_pipe_gate_accepts_only_real_links() {
assert!(is_actionable("qobuzapp://album/1"));
assert!(is_actionable("qbz://album/1"));
assert!(is_actionable("https://play.qobuz.com/album/1"));
assert!(!is_actionable("https://spotify.com/track/1"));
assert!(!is_actionable("qbz://"));
assert!(!is_actionable("not a url at all"));
}
#[test]
fn first_matching_argument_wins() {
let args = vec![
+10
View File
@@ -1165,6 +1165,16 @@ fn collect_output_sinks() -> (Option<String>, Vec<String>, Option<(String, Strin
/// right now (vs the saved "Preferred Sample Rate"). Linux/PipeWire/Pulse only;
/// `None` when pactl is unavailable (a Flatpak/Snap sandbox without it simply
/// degrades to "—", same as the reference). READ-ONLY.
/// W14, Windows only: `pactl` is a Pulse/PipeWire tool and does not exist on
/// Windows, so the row degrades to the same "-" the missing-pactl path already
/// produced -- without the spawn. macOS and the BSDs keep probing: a Homebrew
/// or ports `pactl` there is real and can answer.
#[cfg(windows)]
fn active_sink_format() -> Option<(String, String)> {
None
}
#[cfg(not(windows))]
fn active_sink_format() -> Option<(String, String)> {
use std::process::Command;
let default = Command::new("pactl")
+6 -2
View File
@@ -622,8 +622,12 @@ impl qbz_icon_tint::QbzIconTint {
}
}
// Absolute paths start with '/', so this yields file:///home/...
QString::from(&format!("file://{}", root.join(&hex).display()))
// NOT format!("file://{path}"): on Windows that yields
// file://C:/... where the drive parses as the URL HOST, and the
// icon renders as an empty rectangle rather than an error.
QString::from(&qbz_models::fs_url::file_url(
&root.join(&hex).to_string_lossy(),
))
}
}
+1 -1
View File
@@ -791,7 +791,7 @@ pub fn set_session_artwork(path: &str) {
// `file://`), because that is the form the whole taxonomy downstream
// expects.
let ids: Vec<u64> = tracks.iter().map(|t| t.id as u64).collect();
let url = format!("file://{path}");
let url = qbz_models::fs_url::file_url(&path);
crate::spawn(async move {
let runtime = crate::app();
if runtime.core().patch_queue_artwork(&ids, &url).await {
+6 -2
View File
@@ -976,9 +976,13 @@ mod tests {
assert!(one.artwork_url.as_deref().unwrap().contains("Disc 01"));
assert!(five.artwork_url.as_deref().unwrap().contains("Disc 05"));
let one_resolved = crate::artwork_qt::cached_path(one.artwork_url.as_deref().unwrap());
// Compare separator-normalised: a `file://` URL is always `/`-separated,
// so the round trip through it cannot hand back a host's backslashes.
// Both forms open the same file on Windows.
let slash = |p: &str| p.replace('\\', "/");
assert_eq!(
crate::artwork_qt::local_path(&one_resolved),
disc_one.join("cover.jpg").to_string_lossy()
slash(&crate::artwork_qt::local_path(&one_resolved)),
slash(&disc_one.join("cover.jpg").to_string_lossy())
);
std::fs::remove_dir_all(&box_dir).unwrap();
+3 -7
View File
@@ -719,13 +719,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
album: album.clone(),
album_version: None,
duration_secs: *duration_secs,
artwork_url: artwork_path.clone().map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
}),
artwork_url: artwork_path
.clone()
.map(|path| qbz_models::fs_url::file_url(&path)),
hires: bit_depth.map(|depth| depth >= 24).unwrap_or(false),
bit_depth: *bit_depth,
sample_rate: *sample_rate,
+167
View File
@@ -1,3 +1,9 @@
// No console window beside the GUI on Windows (W4). The file logger
// (`qbz_log::install`) is unaffected; only the inherited stdio console goes.
// If the offscreen smoke ever comes back with an empty log because of this,
// the fix is AttachConsole(ATTACH_PARENT_PROCESS), not reverting this.
#![cfg_attr(windows, windows_subsystem = "windows")]
//! qbz-qt — Qt/QML frontend POC entry point.
//!
//! Phase 1: boot flow (splash -> silent session restore -> shell
@@ -10,6 +16,8 @@ mod auth_qt;
mod deep_link_qt;
#[cfg(target_os = "linux")]
mod single_instance_qt;
// The Windows sibling. Carries its own `#![cfg(target_os = "windows")]`.
mod single_instance_win;
// Per-domain QML bridge singletons (phase 23 — the QbzBridge God-object
// split). THE PATTERN (phase-1, replicated per domain file):
// 1. one #[cxx_qt::bridge] mod per file (crate root — cxx-qt-build
@@ -386,6 +394,73 @@ mod tray_macos;
// macOS) rather than a gated `mod` line, so the QbzShell invokable that
// calls it needs no cfg of its own.
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;
/// Flush the session while Windows waits on WM_QUERYENDSESSION.
///
/// BOUNDED, and that is the whole design. Qt blocks inside `commitDataRequest`
/// until this returns, and Windows starts terminating applications that have
/// not answered after roughly five seconds -- so an unbounded save here does
/// not protect the session, it gets the process killed halfway through writing
/// it, which is worse than not trying.
///
/// So the save runs on its own thread and the GUI thread waits on a deadline
/// comfortably under the OS limit. A `tokio::time::timeout` around the future
/// would not do: it cannot preempt the synchronous SQLite section, which is
/// the part that can actually take time.
///
/// If the deadline passes we return anyway and let the session end. The write
/// either lands in the remaining moments or it does not; either way the shell
/// is not left waiting, and the log says which happened.
///
/// `catch_unwind` because a panic must never cross the C ABI back into Qt's
/// signal emission.
#[cfg(target_os = "windows")]
extern "C" fn on_session_commit_data() {
use std::sync::mpsc::channel;
use std::time::Duration;
/// Well under the ~5 s Windows allows before it stops waiting.
const COMMIT_DEADLINE: Duration = Duration::from_secs(3);
let _ = std::panic::catch_unwind(|| {
log::info!("[qbz-qt] session ending; persisting");
let (tx, rx) = channel::<()>();
let spawned = std::thread::Builder::new()
.name("qbz-session-commit".to_string())
.spawn(move || {
// `save_on_exit` block_on()s the capture on the tokio handle
// bound at shell entry. Safe from a plain thread; it would
// panic only from inside an async context, which this is not.
qbz_app::session_persist::save_on_exit();
let _ = tx.send(());
});
if let Err(e) = spawned {
log::warn!("[qbz-qt] could not spawn the session-commit thread: {e}");
return;
}
// Timeout and Disconnected are DIFFERENT and must not share a line: a
// panic in the worker drops the sender, which arrives here as
// Disconnected, and reporting that as "did not finish in time" would
// hide a crash behind a slow-disk story. The outer catch_unwind cannot
// see it -- it guards this thread, not that one.
match rx.recv_timeout(COMMIT_DEADLINE) {
Ok(()) => log::info!("[qbz-qt] session persisted before the session ended"),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => log::warn!(
"[qbz-qt] session persist did not finish within {}s; letting the session end",
COMMIT_DEADLINE.as_secs()
),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
log::error!("[qbz-qt] the session-persist thread died before reporting")
}
}
});
}
// 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
@@ -3153,6 +3228,12 @@ fn apply_renderer_preference() {
if cfg!(target_os = "macos") {
log::info!("[renderer] '{choice}' on macOS -> Metal default (the Slint GL remap)");
false
} else if cfg!(target_os = "windows") {
// d3d11 is Qt's healthy default here; desktop GL / ANGLE is a
// downgrade, not the GPU tier this pref means. Same remap as
// macOS: honour the intent (GPU path), not the literal backend.
log::info!("[renderer] '{choice}' on Windows -> d3d11 default (the Slint GL remap)");
false
} else {
std::env::set_var("QSG_RHI_BACKEND", "opengl");
log::info!("[renderer] '{choice}' -> QSG_RHI_BACKEND=opengl");
@@ -3297,6 +3378,15 @@ fn main() {
// user-visible. The child uses its own Wayland connection; fatal protocol
// errors cannot take this parent down.
renderer_qt::preflight_saved_gpu_at_boot();
// Same decision, different primitive: a per-session named mutex arbitrates
// and a named pipe carries the handoff. `acquire_or_raise` has already
// forwarded this launch's deep link (or a bare PRESENT) by the time it
// answers false, so there is nothing left to do but leave.
#[cfg(target_os = "windows")]
if !single_instance_win::acquire_or_raise() {
log::info!("[qbz-qt] another instance owns the mutex; forwarded and exiting");
return;
}
// Link anchor for the hand-written QAbstractListModel. Its QML singleton
// registration itself runs at QCoreApplication startup.
local_tracks_model_qt::register_qml_model();
@@ -3398,8 +3488,85 @@ fn main() {
apply_renderer_preference();
apply_scroll_physics();
// FONT ENGINE (Windows). Qt's default rasteriser on Windows renders the
// lyric text thin and jagged - the owner's words were "como fuente de
// bloc de notas con extra alias", and the same text is clean under
// FreeType. Verified on the box by launching with
// QT_QPA_PLATFORM=windows:fontengine=freetype and comparing the lyrics
// panel: same build, same font, only the rasteriser changed.
//
// Only when the variable carries no real value. `offscreen` is what the
// release smoke gate passes here, and overwriting it would make the gate
// test a platform nobody ships. Empty counts as absent: a cleared
// variable is present-with-no-value, which `var_os().is_some()` reports
// as set (the same trap the renderer preference had).
#[cfg(windows)]
{
let explicit = std::env::var_os("QT_QPA_PLATFORM")
.map(|v| !v.to_string_lossy().trim().is_empty())
.unwrap_or(false);
if !explicit {
std::env::set_var("QT_QPA_PLATFORM", "windows:fontengine=freetype");
log::info!("[qbz-qt] font engine -> freetype (Qt's Windows rasteriser aliases text)");
}
}
// The process identity Windows groups the taskbar button under, and the
// one a toast's `app_id` is matched against. Must be set BEFORE any window
// exists -- Windows caches the identity with the first one, and a later
// call is documented to fail.
//
// Without it the taskbar treats each launch as an anonymous window and the
// MSI's Start-menu shortcut (which carries System.AppUserModel.ID) does not
// group with it, and toasts are dropped with no error at all.
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
let id: Vec<u16> = "com.blitzfc.qbz\0".encode_utf16().collect();
// SAFETY: `id` is NUL-terminated UTF-16 and outlives the call, which
// copies it. Called before any window is created, as documented.
let hr = unsafe { SetCurrentProcessExplicitAppUserModelID(id.as_ptr()) };
if hr < 0 {
// Worth a line: when this fails, taskbar grouping and toast
// delivery both break, and both fail SILENTLY. Without this the
// only symptom is "notifications do not work" with nothing to
// point at.
log::warn!("[qbz-qt] SetCurrentProcessExplicitAppUserModelID failed: 0x{hr:08X}");
}
}
// D7 (research/02 §4.4): with no explicit style Windows loads its native
// Controls style, and popups without an explicit `background:` inherit
// native chrome. Pin Basic on Windows so windeployqt ships the style the
// QML was verified against. Preserve Qt's existing platform defaults on
// Linux and macOS. This must run before the first Controls import.
#[cfg(target_os = "windows")]
cxx_qt_lib::QQuickStyle::set_style(&QString::from("Basic"));
let mut app = QGuiApplication::new();
renderer_qt::apply_gpu_preference();
// W25. Windows asks WM_QUERYENDSESSION before it decides to log off or
// shut down, and Qt turns that into `commitDataRequest` and BLOCKS inside
// the signal until the handler returns -- the one moment where a
// synchronous persist is safe. Anything deferred to a queued connection or
// another thread races the process being killed.
//
// WINDOWS ONLY, deliberately, though the hook itself is portable. Qt's
// session manager exists on X11 too, so installing this everywhere would
// add a blocking save to the Linux logout path -- a behaviour change on the
// platform this project leads with, and one that can only ever delay a
// logout. W25 is a Windows contract item; Linux can opt in on its own
// terms, deliberately, rather than inheriting it from a port.
#[cfg(target_os = "windows")]
win_shell::install_commit_data_handler(on_session_commit_data);
// W27's other half. Main.qml asks for CustomizeWindowHint so Qt does not
// paint its own button cluster over the QBZ header; this gives the window
// back a hit test, which that hint otherwise answers as HTNOWHERE for
// every pixel.
#[cfg(target_os = "windows")]
win_shell::install_hittest_filter();
let mut engine = QQmlApplicationEngine::new();
// ── THE APP TYPEFACE, AND WHY IT IS SET RIGHT HERE ────────────────────
+87 -5
View File
@@ -105,12 +105,89 @@ pub(crate) fn init() {
if CONTROLS.get().is_some() {
return;
}
match qbz_media_controls::spawn(dispatch) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
// NON-WINDOWS: unchanged, deliberately. An earlier draft routed every
// platform through the GUI hop and that was a Linux/macOS regression on
// three counts: `tray_bridge::ui` is a NO-OP until `QbzTray::boot`
// registers the Qt thread (`tray_bridge.rs:172`) and `QbzSession.boot()`
// runs first (`Main.qml:254` vs `:380`), so initialization could be
// dropped outright; MPRIS would start only when the Qt queue drained,
// losing the first metadata push; and `main_window_hwnd()` calls
// `QWindow::winId()`, which CREATES the platform window as a side effect
// on platforms that had no reason to.
#[cfg(not(target_os = "windows"))]
{
match qbz_media_controls::spawn(dispatch, qbz_media_controls::NativeWindow::default()) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
}
None => log::info!("[media-controls] no integration on this platform"),
}
None => log::info!("[media-controls] no integration on this platform"),
}
#[cfg(target_os = "windows")]
init_windows();
}
/// Windows needs the main window's HWND before SMTC can register, and it needs
/// to read it on the GUI thread. Both of the things it waits for -- the tray
/// bridge's Qt thread and a shown top-level window -- are "not yet", never
/// "not ever", so this retries on a bounded schedule instead of giving up on
/// the first miss the way a single hop would.
#[cfg(target_os = "windows")]
fn init_windows() {
use std::sync::atomic::{AtomicBool, Ordering};
// Gate on QUEUED, not on CONTROLS: `init` can be called more than once,
// and CONTROLS is only set at the END of a successful spawn, so gating on
// it alone would let several initializers race and build duplicate
// integrations before one wins the OnceLock.
static QUEUED: AtomicBool = AtomicBool::new(false);
if QUEUED.swap(true, Ordering::SeqCst) {
return;
}
// A plain thread, because it is allowed to sleep: this must not occupy a
// tokio worker, and it must not be the GUI thread it is posting to.
let spawned = std::thread::Builder::new()
.name("qbz-smtc-init".to_string())
.spawn(|| {
// 40 x 250 ms = 10 s, comfortably longer than a cold window show.
for _ in 0..40 {
if CONTROLS.get().is_some() {
return;
}
crate::tray_bridge::ui(|_b| {
if CONTROLS.get().is_some() {
return;
}
let native = qbz_media_controls::NativeWindow {
hwnd: crate::win_shell::main_window_hwnd().map(|p| p.as_ptr()),
};
if native.hwnd.is_none() {
// No top-level window yet. Say nothing and let the
// next tick try -- logging here would print forty
// times for a condition that resolves itself.
return;
}
match qbz_media_controls::spawn(dispatch, native) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started (SMTC)");
}
None => log::info!("[media-controls] no integration on this platform"),
}
});
std::thread::sleep(std::time::Duration::from_millis(250));
}
if CONTROLS.get().is_none() {
log::warn!(
"[media-controls] SMTC never started: no top-level window after 10 s"
);
}
});
if let Err(e) = spawned {
log::warn!("[media-controls] could not spawn the SMTC init thread: {e}");
}
}
@@ -140,6 +217,10 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
// smaller set of users.
maybe_notify(track, title, album);
push_metadata(track, title, album);
// BEFORE the early return, and for the same reason `maybe_notify` is:
// keeping the machine awake has nothing to do with whether the OS media
// integration started, and a track is about to play either way.
qbz_media_controls::set_sleep_inhibit(true);
let Some(mc) = handle() else {
return;
};
@@ -223,6 +304,7 @@ pub(crate) fn clear_now_playing() {
/// transitions the MPRIS `Position` getter answers from the last value pushed
/// here — the same behaviour as the Slint build.
pub(crate) fn push_playback_state(is_playing: bool, position_secs: u64) {
qbz_media_controls::set_sleep_inhibit(is_playing);
let Some(mc) = handle() else {
return;
};
+3 -1
View File
@@ -1470,7 +1470,9 @@ fn resolve_from_tracks(item: &MixtapeCollectionItem, tracks: &[QueueTrack]) -> R
// artwork channel reads a bare filesystem path.
let artwork_url = first
.and_then(|t| t.artwork_url.clone())
.map(|u| u.strip_prefix("file://").map(str::to_string).unwrap_or(u))
// NOT strip_prefix: the artwork channel wants a bare filesystem path,
// and on Windows a real file:// URL strips to `/C:/Users/...`.
.map(|u| qbz_models::fs_url::local_path(&u))
.unwrap_or_default();
let artist_id = first
+13 -2
View File
@@ -67,6 +67,10 @@ pub struct NowPlayingModel {
pub quality_tier: String,
/// CATALOG detail, e.g. "24-bit / 96 kHz".
pub quality_detail: String,
/// The OPENED STREAM's bit-perfect mode, straight from the engine:
/// "direct" | "plugin" | "disabled" | "unknown". Never derived from
/// settings - that is what the mode LED already does.
pub bit_perfect_mode: String,
/// Delivered-vs-catalog block (downgrade arrow + tooltip cause).
pub delivered: Delivered,
/// Last delivered stream params seen from the engine, so a poll tick that
@@ -160,6 +164,8 @@ fn publish(m: &NowPlayingModel) {
b.as_mut().set_np_quality_detail(detail.clone());
// Legacy alias of the same value (pre-contract Qt name).
b.as_mut().set_np_quality_label(detail);
b.as_mut()
.set_np_bit_perfect_mode(QString::from(m.bit_perfect_mode.as_str()));
b.as_mut().set_np_quality_downgraded(m.delivered.downgraded);
b.as_mut()
.set_np_quality_true_detail(QString::from(m.delivered.true_detail.as_str()));
@@ -377,6 +383,7 @@ pub fn set_track(meta: TrackMeta) {
m.cache = 0.0;
m.quality_tier = meta.quality_tier;
m.quality_detail = meta.quality_label;
m.bit_perfect_mode = String::new();
m.delivered = Delivered::default();
m.eff_rate_hz = 0;
m.eff_bits = 0;
@@ -405,13 +412,17 @@ pub fn set_catalog_quality(bit_depth: Option<u32>, sample_rate: Option<f64>, gov
/// (`sample_rate` / `bit_depth`; 0 = not reported yet). Re-evaluates the
/// downgrade block and republishes ONLY when the params actually moved, so a
/// steady stream costs one atomic compare per tick and no Qt hop.
pub fn set_effective_stream(eff_rate_hz: u32, eff_bits: u32) {
pub fn set_effective_stream(eff_rate_hz: u32, eff_bits: u32, bit_perfect: &str) {
let (changed, snapshot) = with_model(|m| {
if m.eff_rate_hz == eff_rate_hz && m.eff_bits == eff_bits {
if m.eff_rate_hz == eff_rate_hz
&& m.eff_bits == eff_bits
&& m.bit_perfect_mode == bit_perfect
{
return false;
}
m.eff_rate_hz = eff_rate_hz;
m.eff_bits = eff_bits;
m.bit_perfect_mode = bit_perfect.to_string();
m.delivered = crate::quality_state::evaluate(eff_rate_hz, eff_bits);
true
});
+7 -9
View File
@@ -377,21 +377,19 @@ pub fn remove_album(album_id: String) {
/// Open the cache directory in the desktop file manager (Slint `open_folder`).
///
/// `xdg-open` / `open` by hand rather than the `rfd` used elsewhere in the
/// port: rfd opens a PICKER, and this row is "show me the folder". Same two
/// binaries the reference shells out to.
/// Not the `rfd` used elsewhere in the port: rfd opens a PICKER, and this row
/// is "show me the folder". Routed through the `open` crate like the other
/// eleven sites in this crate — the hand-rolled `xdg-open` fallback ran on
/// every non-macOS host, Windows included, where it does not exist. `open`
/// uses ShellExecuteW there and xdg-open/open on Linux/macOS, so the two
/// supported platforms behave exactly as before.
pub fn open_folder() {
crate::spawn(async move {
let Some(off) = offline_qt::get().await else {
return;
};
let path = off.get_cache_path();
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
if let Err(e) = open::that(&path) {
log::warn!("[qbz-qt] open offline folder failed: {e}");
}
});
+11
View File
@@ -57,6 +57,7 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels {
Some(AudioBackendType::Alsa) => ("ALSA", true),
Some(AudioBackendType::Jack) => ("JACK", true),
Some(AudioBackendType::Pulse) => ("PULS", true),
Some(AudioBackendType::WasapiExclusive) => ("WASAPI", true),
Some(AudioBackendType::SystemDefault) => ("SYST", false),
None => ("AUTO", false),
};
@@ -88,6 +89,16 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels {
}
}
Some(AudioBackendType::Pulse) => ("SHARED", false),
// Exclusive needs a CHOSEN device: with none the player falls through
// to the shared stream, and lighting EXCL there would be the LED
// lying, which is the one thing these two must never do.
Some(AudioBackendType::WasapiExclusive) => {
if audio.output_device.is_some() {
("EXCL", true)
} else {
("SHARED", false)
}
}
Some(AudioBackendType::SystemDefault) | None => ("DEFAULT", false),
};
OutputLabels {
+12 -7
View File
@@ -1217,13 +1217,7 @@ async fn offline_album_queue(album_id: &str) -> Result<Vec<QueueTrack>, String>
let artwork = local
.and_then(|row| row.artwork_path.clone())
.or_else(|| cached.resolve_cover_path(&cache_path))
.map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
});
.map(|path| qbz_models::fs_url::file_url(&path));
let album = cached.album.clone().unwrap_or_default();
let sample_rate = cached
.sample_rate
@@ -3443,9 +3437,20 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// DELIVERED stream params -> the downgrade arrow + tooltip cause.
// Deduped inside, so a steady stream costs no Qt-thread hop.
// The mode the STREAM negotiated, not the one the settings asked
// for. `output_labels.rs` already derives the LED from settings;
// this is the only value that can contradict it, which is exactly
// what makes it worth publishing.
let bit_perfect = match event.bit_perfect_mode {
Some(qbz_audio::BitPerfectMode::DirectHardware) => "direct",
Some(qbz_audio::BitPerfectMode::PluginFallback) => "plugin",
Some(qbz_audio::BitPerfectMode::Disabled) => "disabled",
None => "unknown",
};
crate::now_playing::set_effective_stream(
event.sample_rate.unwrap_or(0),
event.bit_depth.unwrap_or(0),
bit_perfect,
);
// --- Gapless prefetch trigger (playback.rs:5362-5476) ---------
+8
View File
@@ -71,6 +71,12 @@ pub mod qbz_player {
// Legacy alias of np_quality_detail (the pre-contract Qt name) —
// published from the same value so existing QML keeps working.
#[qproperty(QString, np_quality_label)]
// What the OPENED STREAM actually negotiated: "direct" | "plugin" |
// "disabled" | "unknown". Distinct from the mode LED on purpose - that
// one is derived from SETTINGS (output_labels.rs), so it says what was
// ASKED FOR. Only this one can tell "exclusive worked" from "we asked
// for exclusive", which is what phase B's acceptance turns on.
#[qproperty(QString, np_bit_perfect_mode)]
// DELIVERED-vs-catalog state (#590/#638). While `downgraded` is on,
// the stamp's main line reports the delivered tier/detail and the
// catalog max moves to the tooltip's "Source" line.
@@ -300,6 +306,7 @@ pub struct QbzPlayerRust {
np_quality_tier: QString,
np_quality_detail: QString,
np_quality_label: QString,
np_bit_perfect_mode: QString,
np_quality_downgraded: bool,
np_quality_true_detail: QString,
np_quality_effective_tier: QString,
@@ -355,6 +362,7 @@ impl Default for QbzPlayerRust {
np_repeat_mode: 0,
np_quality_detail: QString::default(),
np_quality_label: QString::default(),
np_bit_perfect_mode: QString::default(),
np_quality_downgraded: false,
np_quality_true_detail: QString::default(),
np_quality_effective_tier: QString::default(),
+7 -11
View File
@@ -71,18 +71,14 @@ pub fn resolve_qconnect_device_type() -> i32 {
}
pub fn resolve_system_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.trim().is_empty() {
return h.trim().to_string();
}
// One implementation, in qbz-app: this copy read HOSTNAME then
// /etc/hostname, neither of which Windows has, so every Windows install
// announced itself with the same name. The "Desktop" last resort is kept
// here rather than pushed down: qbzd's differs, and both are user-visible.
match qbz_app::settings::bundle::hostname().as_str() {
"unknown" => "Desktop".to_string(),
h => h.to_string(),
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
"Desktop".to_string()
}
/// Returns "Qbz - {hostname}" as the default device name.
+139
View File
@@ -412,6 +412,100 @@ fn update_prefs(edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value
let _ = edit_prefs(|doc| (edit(doc), ()));
}
/// Can this install actually deliver a toast?
///
/// Setting the process AUMID is necessary and NOT sufficient. An unpackaged
/// desktop app only receives toasts once Windows can RESOLVE that id, which it
/// does through either a Start-menu shortcut carrying
/// `System.AppUserModel.ID` or the `AppUserModelId` registry key. The MSI
/// writes both; a portable unzip has neither.
///
/// So the notifications row is offered on the strength of THIS, not of
/// `cfg!(windows)`. Advertising it unconditionally would put a switch in front
/// of portable users that can never produce a notification -- the "renders,
/// persists and drives nothing" failure the capability flags exist to prevent.
#[cfg(target_os = "windows")]
pub(crate) fn windows_toast_identity_registered() -> bool {
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
};
let key: Vec<u16> = "Software\\Classes\\AppUserModelId\\com.blitzfc.qbz\0"
.encode_utf16()
.collect();
let mut handle: HKEY = std::ptr::null_mut();
// SAFETY: `key` is NUL-terminated UTF-16 and outlives the call; `handle` is
// a valid out-slot, only written on success. Read-only access.
let rc = unsafe {
RegOpenKeyExW(
HKEY_CURRENT_USER,
key.as_ptr(),
0,
KEY_READ,
&mut handle,
)
};
if rc != 0 {
return false;
}
// SAFETY: opened above and not used afterwards.
unsafe {
RegCloseKey(handle);
}
true
}
/// The Windows as-is disclaimer, kept as two fields (owner's call): the
/// "Don't show this again" box and the version it was ticked on.
pub(crate) const WINDOWS_DISCLAIMER_HIDDEN_KEY: &str = "windows_disclaimer_hidden";
pub(crate) const WINDOWS_DISCLAIMER_VERSION_KEY: &str = "windows_disclaimer_ack_version";
/// Both fields out of ONE parsed document.
///
/// Reading them with two `pref_*` calls would parse the file twice and could
/// synthesise a pair that never existed in a single snapshot -- the shared
/// `ui_prefs.json` has another writer (the Slint app) and this one is read at
/// construction, while it is most likely to be mid-write.
pub(crate) fn windows_disclaimer_state() -> (bool, String) {
let Some(path) = prefs_path() else {
return (false, String::new());
};
let Some(doc) = read_json_object(&path) else {
return (false, String::new());
};
let hidden = doc
.get(WINDOWS_DISCLAIMER_HIDDEN_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
let version = doc
.get(WINDOWS_DISCLAIMER_VERSION_KEY)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(hidden, version)
}
/// Both fields into ONE transaction, and report whether it landed.
///
/// Two `save_pref` calls would be two whole-document read-modify-writes, and a
/// crash or a failed rename between them leaves a torn pair -- `hidden` with no
/// version, or a version with no `hidden`. Either half alone re-opens the
/// modal, so the user would tick the box and see it again.
pub(crate) fn save_windows_disclaimer_ack(version: &str) -> bool {
edit_prefs(|doc| {
doc.insert(
WINDOWS_DISCLAIMER_HIDDEN_KEY.to_string(),
serde_json::Value::Bool(true),
);
doc.insert(
WINDOWS_DISCLAIMER_VERSION_KEY.to_string(),
serde_json::Value::String(version.to_string()),
);
(true, ())
})
.is_some()
}
/// Flip a shared bool pref in ONE document — read and write inside the same
/// read-modify-write, never `pref_bool` then `save_pref`. `None` = the
/// document was unreadable, so nothing was written and nothing flipped.
@@ -1605,6 +1699,11 @@ pub struct SettingsDoc {
pub backend_index: i32,
#[serde(rename = "backendIsAlsa")]
pub backend_is_alsa: bool,
/// True when the WASAPI exclusive backend is the active one. AudioSettings
/// QML gates the Exclusive-mode and DSD rows on it - see the comment there
/// for why they are hidden rather than disabled.
#[serde(rename = "backendIsWasapi")]
pub backend_is_wasapi: bool,
#[serde(rename = "backendIsPipewire")]
pub backend_is_pipewire: bool,
#[serde(rename = "backendIsJack")]
@@ -1828,6 +1927,20 @@ pub struct SettingsDoc {
/// `AppearanceState.is-macos`.
#[serde(rename = "isMacos")]
pub is_macos: bool,
#[serde(rename = "isWindows")]
pub is_windows: bool,
/// A tray backend exists on this platform (`tray_qt::init` would create
/// one). Phase A: Linux + macOS. Plan A-2 Task 5 adds Windows.
#[serde(rename = "traySupported")]
pub tray_supported: bool,
/// `notify.rs` has a real arm here. Phase A: Linux + macOS. Plan A-2
/// Task 3 adds Windows.
#[serde(rename = "systemNotificationsSupported")]
pub system_notifications_supported: bool,
/// The RENDERER group offers a real choice: Linux (Vulkan/GL/software)
/// and Windows (D3D11/D3D12/GL/software). macOS is always Metal.
#[serde(rename = "rendererSelectable")]
pub renderer_selectable: bool,
#[serde(rename = "trayIconThemes")]
pub tray_icon_themes: Vec<String>,
#[serde(rename = "trayIconThemeIndex")]
@@ -2017,6 +2130,8 @@ fn backend_label(t: AudioBackendType) -> String {
AudioBackendType::Pulse => "PulseAudio".to_string(),
AudioBackendType::SystemDefault => qbz_i18n::t("System default"),
AudioBackendType::Jack => "JACK".to_string(),
// Not translated, like the other backend proper nouns above it.
AudioBackendType::WasapiExclusive => "WASAPI Exclusive".to_string(),
}
}
@@ -2100,6 +2215,7 @@ pub async fn publish_snapshot() {
.collect(),
backend_index: backend_index as i32 + 1,
backend_is_alsa: active_backend == AudioBackendType::Alsa,
backend_is_wasapi: active_backend == AudioBackendType::WasapiExclusive,
backend_is_pipewire: active_backend == AudioBackendType::PipeWire,
backend_is_jack: active_backend == AudioBackendType::Jack,
devices,
@@ -2273,6 +2389,29 @@ pub async fn publish_snapshot() {
.map(|t| t.mac_hide_dock)
.unwrap_or(false),
is_macos: cfg!(target_os = "macos"),
is_windows: cfg!(target_os = "windows"),
// Windows joined with A-2 T5 (Shell_NotifyIconW).
tray_supported: cfg!(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
)),
// Windows answers at RUNTIME, not by cfg: the toast arm is
// implemented, but delivery needs a registered AppUserModelID,
// which only the MSI provides. A portable unzip gets `false` and
// the row stays hidden rather than offering a switch that can
// never produce a notification.
system_notifications_supported: {
#[cfg(target_os = "windows")]
{
windows_toast_identity_registered()
}
#[cfg(not(target_os = "windows"))]
{
cfg!(any(target_os = "linux", target_os = "macos"))
}
},
renderer_selectable: cfg!(any(target_os = "linux", target_os = "windows")),
tray_icon_themes: TRAY_ICON_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
tray_icon_theme_index: tray()
.get_settings()
+83
View File
@@ -206,6 +206,23 @@ pub mod qbz_shell {
// `cfg!`, and AppearanceSettings.slint:1011-1043 gates the whole
// group AND the Preferred-GPU row on it).
#[qproperty(bool, is_linux)]
// Windows is neither of the two above, and QML must be able to say
// so: without it every two-way `isMacos ? ... : ...` ternary hands
// Windows the LINUX arm by default. Compile-time, like both others.
#[qproperty(bool, is_windows)]
// The Windows as-is disclaimer. True while the modal should be up.
//
// Seeded at CONSTRUCTION, like the window geometry above and for the
// same reason: Main.qml binds a modal to it and the first frame is
// already too late for a post-boot push.
//
// What it is seeded FROM is a version string, not a boolean. "Don't
// show again" stores the version that was acknowledged, so a build
// whose version differs from the stored one shows it again with no
// separate reset step -- the reset IS the comparison. Dismissing
// WITHOUT the box ticked only clears this property, so the next launch
// asks once more.
#[qproperty(bool, windows_disclaimer_open)]
// --- Main-window geometry -----------------------------------------
// The restored LOGICAL size + maximized flag from the shared
// ui_prefs.json (settings_qt::window_size / window_maximized; the
@@ -453,6 +470,12 @@ pub mod qbz_shell {
#[qinvokable]
fn report_renderer_api(self: Pin<&mut QbzShell>, api: QString);
/// Close the Windows disclaimer. `remember` = the "Don't show this
/// again" box was ticked, which persists the CURRENT version as
/// acknowledged; the next release therefore shows it again.
#[qinvokable]
fn dismiss_windows_disclaimer(self: Pin<&mut QbzShell>, remember: bool);
/// The frame-liveness watchdog's verdict (PARITY-DEBT #104's other
/// owed half). Reported once by `Main.qml` after a settling window,
/// with the number of frames the window actually swapped in it.
@@ -790,6 +813,8 @@ pub struct QbzShellRust {
invert_swipe_navigation: bool,
is_macos: bool,
is_linux: bool,
is_windows: bool,
windows_disclaimer_open: bool,
window_width: f32,
window_height: f32,
window_maximized: bool,
@@ -894,6 +919,8 @@ impl Default for QbzShellRust {
// the same `cfg!` (main.rs seeds AppearanceState at startup).
is_macos: cfg!(target_os = "macos"),
is_linux: cfg!(target_os = "linux"),
is_windows: cfg!(target_os = "windows"),
windows_disclaimer_open: crate::shell_bridge::windows_disclaimer_pending(),
window_width,
window_height,
window_maximized: crate::settings_qt::window_maximized(),
@@ -1083,6 +1110,39 @@ pub fn multi_select_capable() -> bool {
MULTI_SELECT_CAPABLE.load(std::sync::atomic::Ordering::SeqCst)
}
/// The Windows as-is disclaimer, stored in `ui_prefs.json` as TWO fields
/// (owner's call, 2026-08-27):
///
/// - `windows_disclaimer_hidden` -- the "Don't show this again" box;
/// - `windows_disclaimer_ack_version` -- the version it was ticked on.
///
/// Two rather than one so the boolean means something on its own: a future
/// "show it again" control can flip it without inventing a version to write,
/// and reading the file tells you both what the user chose and when.
///
/// KNOWN AND ACCEPTED: reinstalling the SAME version does not bring the modal
/// back, because `ui_prefs.json` lives in %APPDATA% and outlives the
/// installer. A version CHANGE does bring it back, which is the requirement.
/// If the same-version case ever matters, the MSI is the place to clear these
/// two keys.
/// Should the Windows as-is disclaimer be shown for THIS build?
///
/// Shown unless the user hid it AND hid it on this exact version. A newer
/// build therefore shows it again with nothing to reset by hand.
///
/// Both fields come out of ONE parsed document; the keys and the write live in
/// `settings_qt`, which owns `ui_prefs.json`'s read-modify-write discipline.
///
/// Windows only. On every other platform the modal does not exist, and this
/// answers false without touching the prefs file.
pub(crate) fn windows_disclaimer_pending() -> bool {
if !cfg!(target_os = "windows") {
return false;
}
let (hidden, acked) = crate::settings_qt::windows_disclaimer_state();
!hidden || acked != env!("CARGO_PKG_VERSION")
}
impl qbz_shell::QbzShell {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
@@ -1105,6 +1165,29 @@ impl qbz_shell::QbzShell {
/// `renderer_qt::reduce_motion` rather than assigning the tier directly —
/// the kiosk toggle writes the same property, and a bare assignment from
/// either side would erase the other's contribution.
/// Close the Windows disclaimer, optionally for this version.
///
/// `remember == false` clears the property only: the modal is gone for
/// this run and comes back on the next launch, which is what a plain
/// "Close" should mean.
pub fn dismiss_windows_disclaimer(mut self: Pin<&mut Self>, remember: bool) {
self.as_mut().set_windows_disclaimer_open(false);
if !remember {
return;
}
// One transaction, and the log follows the RESULT: `save_pref` reports
// nothing, so an unconditional "acknowledged" line would claim a write
// that never happened whenever the document was unreadable.
if crate::settings_qt::save_windows_disclaimer_ack(env!("CARGO_PKG_VERSION")) {
log::info!(
"[qbz-qt] Windows disclaimer acknowledged for v{}",
env!("CARGO_PKG_VERSION")
);
} else {
log::warn!("[qbz-qt] could not persist the Windows disclaimer acknowledgement");
}
}
pub fn report_renderer_api(mut self: Pin<&mut Self>, api: QString) {
let api = api.to_string();
if !crate::renderer_qt::set_active_api(&api) {
+297
View File
@@ -0,0 +1,297 @@
//! Windows single instance, and the deep-link handoff that rides on it.
//!
//! The sibling of `single_instance_qt.rs`, which does the same job on Linux
//! over the session bus. Same three entry points, same `present_or_defer`
//! behaviour, so `main.rs` reads the same either way.
//!
//! ## Why a mutex AND a pipe, rather than one of them
//!
//! Arbitration and transport are different problems and Windows solves them
//! with different objects.
//!
//! `CreateMutexW` arbitrates ATOMICALLY: the kernel either creates the object
//! or tells you it already existed, with no window in between for a second
//! launch to slip through. A named pipe cannot be asked that question without
//! `FILE_FLAG_FIRST_PIPE_INSTANCE`, and a Qt `QLocalServer` never passes it
//! (`qlocalserver_win.cpp` opens with `PIPE_UNLIMITED_INSTANCES`), so two
//! processes can both believe they are the server.
//!
//! The pipe then carries the message. The plan called for QLocalServer here,
//! which would have meant a new `Q_OBJECT` C++ file in the moc list and
//! `.qt_module("Network")` in the build. A raw named pipe is the same
//! primitive underneath with none of that build surface, and this module owns
//! both halves of a protocol that is two verbs long.
//!
//! ## The race the retry exists for
//!
//! The primary creates the mutex BEFORE its pipe server is listening. A second
//! launch inside that gap finds the mutex taken and the pipe absent, so it
//! must WAIT for the pipe rather than conclude nothing is there -- otherwise
//! the deep link that started it is dropped on the floor.
#![cfg(target_os = "windows")]
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Duration;
/// `Local\` = per-session, which matches the per-user MSI: two users logged in
/// at once each get their own QBZ, exactly as they each get their own D-Bus
/// session on Linux.
const MUTEX_NAME: &str = "Local\\com.blitzfc.qbz.singleton\0";
/// The pipe name, QUALIFIED BY SESSION ID.
///
/// The mutex is `Local\`, which is per-session. Pipe names are NOT: the
/// `\\.\pipe\` namespace is machine-global and has no session prefix. With one
/// fixed name and two users signed in at once, each acquires its own mutex
/// (different namespace) but only the first can create the pipe -- the second
/// primary then serves nothing, and ITS later launches would forward to the
/// OTHER USER'S process. Qualifying by session id restores the pairing the
/// mutex already had.
fn pipe_name() -> String {
format!("\\\\.\\pipe\\com.blitzfc.qbz.singleton.{}", session_id())
}
/// This process's Terminal Services session. 0 on failure, which is still a
/// consistent answer -- every instance that fails agrees on it.
fn session_id() -> u32 {
use windows_sys::Win32::System::RemoteDesktop::ProcessIdToSessionId;
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
let mut id: u32 = 0;
// SAFETY: `id` is a valid out-slot; the call only writes it.
let ok = unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut id) };
if ok == 0 {
0
} else {
id
}
}
/// Held for the process lifetime and deliberately never closed: releasing it
/// would let a second instance start while this one is still running.
static MUTEX: OnceLock<usize> = OnceLock::new();
static UI_READY: AtomicBool = AtomicBool::new(false);
static PENDING_PRESENT: AtomicBool = AtomicBool::new(false);
/// Called once QbzTray has registered its Qt-thread hop, exactly as the Linux
/// module is. Independent of whether the tray icon itself is enabled.
pub(crate) fn bind_ui() {
UI_READY.store(true, Ordering::SeqCst);
if PENDING_PRESENT.swap(false, Ordering::SeqCst) {
crate::tray_qt::present();
}
}
/// Present now if the UI can take it, or remember to present when it can.
///
/// A forwarded message can arrive before the QML shell exists; presenting into
/// that gap does nothing and the user's second launch looks ignored.
fn present_or_defer() {
if UI_READY.load(Ordering::SeqCst) {
crate::tray_qt::present();
return;
}
PENDING_PRESENT.store(true, Ordering::SeqCst);
// RE-CHECK, and this is not belt-and-braces. `bind_ui` can set UI_READY
// and run its own swap in the window between the load above and the store
// below it; the flag would then be set with nobody left to consume it, and
// the user's second launch would look ignored. Reading UI_READY again
// after publishing closes that order.
if UI_READY.load(Ordering::SeqCst) && PENDING_PRESENT.swap(false, Ordering::SeqCst) {
crate::tray_qt::present();
}
}
/// True when this process should continue as the primary instance.
pub(crate) fn acquire_or_raise() -> bool {
use windows_sys::Win32::Foundation::{GetLastError, ERROR_ALREADY_EXISTS};
use windows_sys::Win32::System::Threading::CreateMutexW;
let name: Vec<u16> = MUTEX_NAME.encode_utf16().collect();
// SAFETY: NUL-terminated UTF-16 that outlives the call; a null security
// descriptor asks for the default, which is what a per-session object
// wants.
let handle = unsafe { CreateMutexW(std::ptr::null(), 1, name.as_ptr()) };
if handle.is_null() {
// Cannot arbitrate. Continuing as primary is the safe failure: the
// worst case is two windows, where bailing out would be no window.
log::warn!("[qbz-qt] CreateMutexW failed; continuing as the primary instance");
return true;
}
// SAFETY: reads this thread's last-error, set by the call above.
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
forward_to_primary();
return false;
}
let _ = MUTEX.set(handle as usize);
start_server();
true
}
/// Hand our argv to the instance that owns the mutex.
///
/// RETRIES, because the primary creates the mutex before it listens. Ten
/// attempts at 100 ms covers the gap without making a genuinely absent server
/// (a primary that died between the two) cost more than a second.
fn forward_to_primary() {
let line = match crate::deep_link_qt::take_pending() {
Some(url) => format!("OPEN {url}\n"),
None => "PRESENT\n".to_string(),
};
for attempt in 0..10 {
match std::fs::OpenOptions::new().write(true).open(pipe_name()) {
Ok(mut pipe) => {
if let Err(e) = pipe.write_all(line.as_bytes()) {
log::warn!("[qbz-qt] forwarding to the primary instance failed: {e}");
}
let _ = pipe.flush();
log::info!(
"[qbz-qt] another instance owns the mutex; forwarded {}",
line.split_whitespace().next().unwrap_or("?")
);
return;
}
Err(_) if attempt < 9 => std::thread::sleep(Duration::from_millis(100)),
Err(e) => {
log::warn!("[qbz-qt] the primary instance is not listening ({e}); exiting anyway")
}
}
}
}
/// Serve the pipe for the life of the process.
///
/// One connection at a time is enough: the messages are two words long and a
/// second launch is a human action. A fresh pipe instance is created per
/// connection because `std::fs::File` closes its handle on drop, which
/// disconnects the client cleanly.
fn start_server() {
let spawned = std::thread::Builder::new()
.name("qbz-single-instance".to_string())
.spawn(|| loop {
let Some(handle) = create_pipe_instance() else {
// The name is unusable; without a server this process is still
// a perfectly good primary, so stop serving rather than spin.
log::warn!("[qbz-qt] single-instance pipe unavailable; not listening");
return;
};
if let Some(line) = accept_one(handle) {
handle_line(line.trim());
}
});
if let Err(e) = spawned {
log::warn!("[qbz-qt] could not spawn the single-instance server: {e}");
}
}
fn create_pipe_instance() -> Option<isize> {
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_INBOUND;
use windows_sys::Win32::System::Pipes::{
CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT,
};
let name: Vec<u16> = pipe_name()
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: NUL-terminated UTF-16 alive across the call. Inbound only -- the
// protocol never answers.
//
// The security descriptor is the DEFAULT, and what that means is worth
// stating correctly rather than assuming: full control for SYSTEM,
// administrators and the creator-owner, plus READ for Everyone. Clients
// here ask for write, so another ordinary user cannot inject a line, and
// Mandatory Integrity Control keeps a low-integrity process out. What CAN
// write is a process running as this same user, or an elevated
// administrator -- both of which can already do far worse to a process
// they own. A logon-SID DACL would close the same-user-other-session case
// properly and is the documented way; the session-qualified NAME above
// separates the sessions in practice, and `handle_line` validates what
// arrives rather than trusting it.
//
// PIPE_REJECT_REMOTE_CLIENTS because this is strictly local IPC: the
// namespace is reachable over SMB and nothing about a second launch is.
let h = unsafe {
CreateNamedPipeW(
name.as_ptr(),
PIPE_ACCESS_INBOUND,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
1,
512,
512,
0,
std::ptr::null(),
)
};
if h == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
return None;
}
Some(h as isize)
}
/// Block until one client connects, read what it sends, then close.
fn accept_one(handle: isize) -> Option<String> {
use std::os::windows::io::FromRawHandle;
use windows_sys::Win32::System::Pipes::ConnectNamedPipe;
// SAFETY: a live pipe handle from `create_pipe_instance`. Blocks until a
// client arrives, which is the point of the dedicated thread.
let connected = unsafe { ConnectNamedPipe(handle as _, std::ptr::null_mut()) };
// A client that connected between CreateNamedPipeW and here makes this
// return 0 with ERROR_PIPE_CONNECTED, which is success.
if connected == 0 {
use windows_sys::Win32::Foundation::{GetLastError, ERROR_PIPE_CONNECTED};
// SAFETY: reads this thread's last-error.
if unsafe { GetLastError() } != ERROR_PIPE_CONNECTED {
// SAFETY: taking ownership so the handle is closed on drop.
drop(unsafe { std::fs::File::from_raw_handle(handle as _) });
return None;
}
}
// SAFETY: ownership moves to the File, which closes the handle on drop and
// disconnects the client with it.
let mut file = unsafe { std::fs::File::from_raw_handle(handle as _) };
let mut buf = String::new();
// BOUNDED. The protocol's longest legal line is a URL; anything past this
// is not ours, and an unbounded read on a pipe anyone in this session can
// write is a free way to grow this process without limit.
match file.take(4096).read_to_string(&mut buf) {
Ok(_) => Some(buf),
Err(e) => {
log::warn!("[qbz-qt] single-instance read failed: {e}");
None
}
}
}
/// The whole protocol: two verbs.
fn handle_line(line: &str) {
if let Some(url) = line.strip_prefix("OPEN ") {
let url = url.trim();
if url.is_empty() {
return;
}
// VALIDATE. This is not argv: it arrived on a pipe, and the only
// thing keeping it honest is this check. Anything that is not a link
// we would have accepted on our own command line is dropped.
if !crate::deep_link_qt::is_actionable(url) {
log::warn!("[qbz-qt] single-instance: ignoring an unusable OPEN payload");
return;
}
log::info!(
"[qbz-qt] deep link forwarded from a second instance: {}",
url.split('?').next().unwrap_or(url)
);
crate::deep_link_qt::stash(url.to_string());
present_or_defer();
crate::deep_link_qt::drain_pending();
} else if line == "PRESENT" {
present_or_defer();
}
}
+7
View File
@@ -180,8 +180,15 @@ 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();
#[cfg(target_os = "windows")]
crate::single_instance_win::bind_ui();
}
/// D18's report-back. Flag only — it never touches a window.
+92 -3
View File
@@ -5,8 +5,8 @@
//! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is
//! `tray_bridge.rs` (A-21).
//!
//! Linux (ksni, `tray_linux.rs`) and macOS (`NSStatusItem`, `tray_macos.rs`).
//! Windows has no tray in either tree.
//! Linux uses ksni (`tray_linux.rs`), macOS uses NSStatusItem
//! (`tray_macos.rs`), and Windows uses Shell_NotifyIcon (`tray_windows.rs`).
//!
//! RETIRED 2026-08-05 — the note that used to sit here said macOS "would be a
//! NEW implementation, not a port", because the reference only hand-rolls its
@@ -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,13 +414,61 @@ 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.
//
// 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;
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")))]
{
let _ = theme_override;
log::info!("[tray] no tray backend on this platform");
}
}
/// 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
+149
View File
@@ -0,0 +1,149 @@
//! 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() }
}
+54
View File
@@ -0,0 +1,54 @@
//! Windows shell helpers backed by `cxx/win_shell.cpp` (the `font_qt.rs`
//! pattern: a thin `extern "C"` seam, one safety note per call).
//!
//! The C++ half compiles on every platform, so this module does too. What is
//! Windows-specific is what the callers do with the handle.
use std::ffi::c_void;
use std::ptr::NonNull;
unsafe extern "C" {
fn qbz_main_window_hwnd() -> *mut c_void;
fn qbz_install_commit_data_handler(cb: extern "C" fn());
fn qbz_install_hittest_filter();
}
/// Answer WM_NCHITTEST for the custom-chrome window the way an ordinary
/// borderless-resizable window would.
///
/// Needed because `Qt::CustomizeWindowHint` -- the only way to stop Qt drawing
/// its own window buttons over the QBZ header -- makes Qt return HTNOWHERE for
/// the whole window, and Windows sends no mouse input to HTNOWHERE. See the
/// long note in `cxx/win_shell.cpp`.
#[cfg(target_os = "windows")]
pub(crate) fn install_hittest_filter() {
// SAFETY: no arguments; installs one process-lifetime filter on the
// QCoreApplication, and is idempotent on the C++ side. Called after
// `QGuiApplication::new`, on the GUI thread.
unsafe { qbz_install_hittest_filter() }
}
/// The top-level window's native handle, or `None` before it is shown.
///
/// GUI THREAD ONLY, and never before `exec()`: `QWindow::winId()` creates the
/// platform window as a side effect, so calling it early both returns a handle
/// for a half-built window and changes the handle the real one ends up with.
#[allow(dead_code)] // consumed by Task 2 (SMTC) and Task 5 (tray)
pub(crate) fn main_window_hwnd() -> Option<NonNull<c_void>> {
// SAFETY: takes no arguments and only reads QGuiApplication state. The
// caller guarantees the GUI thread and a live QGuiApplication.
NonNull::new(unsafe { qbz_main_window_hwnd() })
}
/// Run `cb` when Windows asks whether the session may end (WM_QUERYENDSESSION).
///
/// Qt blocks inside the signal until the handler returns, so `cb` must do its
/// work synchronously -- that is the point of the hook, and the reason a
/// queued connection would be wrong here.
#[allow(dead_code)] // consumed by Task 8 (logoff persistence)
pub(crate) fn install_commit_data_handler(cb: extern "C" fn()) {
// SAFETY: `qApp` exists (this runs after `QGuiApplication::new`), and the
// lambda stored on the C++ side only calls `cb`, which is a `'static` fn
// pointer with no captured state.
unsafe { qbz_install_commit_data_handler(cb) }
}