93 files changed
+6794
-261
No files matched your search
@@ -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
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Windows notification-area icon, modelled on Qt's own
|
||||
// qwindowssystemtrayicon.cpp. The whole body is inside `#ifdef _WIN32` so the
|
||||
// file can sit unconditionally in build.rs beside font_fallback.cpp and
|
||||
// compile to nothing on Linux and macOS.
|
||||
|
||||
#include "win_tray.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
// ORDER MATTERS, and it is not alphabetical. <windows.h> must come first:
|
||||
// <shellapi.h> uses DECLSPEC_IMPORT, HDROP and EXTERN_C without declaring
|
||||
// them, so including it first produces a wall of C4430/C2146 inside the SDK
|
||||
// header itself and points nowhere near this file.
|
||||
#include <windows.h>
|
||||
|
||||
#include <shellapi.h>
|
||||
// GET_X_LPARAM / GET_Y_LPARAM: signed extraction, which matters on a
|
||||
// multi-monitor desktop where a secondary display has negative coordinates.
|
||||
#include <windowsx.h>
|
||||
|
||||
#pragma comment(lib, "shell32.lib")
|
||||
#pragma comment(lib, "user32.lib")
|
||||
|
||||
namespace {
|
||||
|
||||
// WM_APP+101: the tray's callback message. Anything in the WM_APP range is
|
||||
// ours to define, and this window class receives nothing else.
|
||||
constexpr UINT kTrayCallback = WM_APP + 101;
|
||||
constexpr UINT kIconId = 1;
|
||||
|
||||
enum MenuId : UINT {
|
||||
kMenuPlayPause = 1001,
|
||||
kMenuNext,
|
||||
kMenuPrevious,
|
||||
kMenuShowHide,
|
||||
kMenuQuit,
|
||||
};
|
||||
|
||||
HWND g_hwnd = nullptr;
|
||||
HICON g_icon = nullptr;
|
||||
// LoadImageW hands us an icon WE own; the LoadIconW(nullptr, ...) fallback is a
|
||||
// shared system icon that must never be destroyed. Which one we got decides
|
||||
// whether qbz_win_tray_destroy calls DestroyIcon.
|
||||
bool g_icon_owned = false;
|
||||
UINT g_taskbar_created = 0; // RegisterWindowMessageW("TaskbarCreated")
|
||||
bool g_playing = false;
|
||||
wchar_t g_tooltip[128] = L"QBZ";
|
||||
|
||||
wchar_t g_label_play_pause[64] = L"Play/Pause";
|
||||
wchar_t g_label_next[64] = L"Next Track";
|
||||
wchar_t g_label_previous[64] = L"Previous Track";
|
||||
wchar_t g_label_show_hide[64] = L"Show/Hide Window";
|
||||
wchar_t g_label_quit[64] = L"Quit QBZ";
|
||||
|
||||
void (*g_on_left_click)() = nullptr;
|
||||
void (*g_on_play_pause)() = nullptr;
|
||||
void (*g_on_next)() = nullptr;
|
||||
void (*g_on_previous)() = nullptr;
|
||||
void (*g_on_quit)() = nullptr;
|
||||
|
||||
void copy_label(wchar_t *dst, size_t cap, const wchar_t *src)
|
||||
{
|
||||
if (!src)
|
||||
return;
|
||||
wcsncpy_s(dst, cap, src, _TRUNCATE);
|
||||
}
|
||||
|
||||
// The icon Windows draws. winresource assigns id 1 to the first icon it
|
||||
// embeds (W4), and LoadImageW at 16x16 picks the matching RT_ICON out of that
|
||||
// group. If it is ever missing we fall back to the generic application icon
|
||||
// rather than returning null: a tray entry with the wrong picture is a cosmetic
|
||||
// bug, a tray entry that never appears is close-to-tray hiding the window with
|
||||
// no way back.
|
||||
//
|
||||
// Sets g_icon_owned: without LR_SHARED the LoadImageW handle is OURS and leaks
|
||||
// unless destroyed, while the LoadIconW fallback is shared and must not be.
|
||||
HICON load_tray_icon()
|
||||
{
|
||||
HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||
const int cx = GetSystemMetrics(SM_CXSMICON);
|
||||
const int cy = GetSystemMetrics(SM_CYSMICON);
|
||||
if (HICON icon = static_cast<HICON>(
|
||||
LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR))) {
|
||||
g_icon_owned = true;
|
||||
return icon;
|
||||
}
|
||||
g_icon_owned = false;
|
||||
// MAKEINTRESOURCEW, not IDI_APPLICATION: this build does not define
|
||||
// UNICODE, so the bare macro expands to the ANSI MAKEINTRESOURCEA and
|
||||
// will not convert to the LPCWSTR LoadIconW wants. 32512 IS
|
||||
// IDI_APPLICATION. Every Win32 call in this file is an explicit W for the
|
||||
// same reason.
|
||||
return LoadIconW(nullptr, MAKEINTRESOURCEW(32512));
|
||||
}
|
||||
|
||||
NOTIFYICONDATAW base_data()
|
||||
{
|
||||
NOTIFYICONDATAW nid = {};
|
||||
nid.cbSize = sizeof(nid);
|
||||
nid.hWnd = g_hwnd;
|
||||
nid.uID = kIconId;
|
||||
return nid;
|
||||
}
|
||||
|
||||
bool add_icon()
|
||||
{
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
// NIF_SHOWTIP is REQUIRED with version 4. Version 4 suppresses the standard
|
||||
// tooltip unless it is asked for, so without this flag the icon appears,
|
||||
// the tooltip is set on every track, and nothing ever shows on hover.
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP;
|
||||
nid.uCallbackMessage = kTrayCallback;
|
||||
nid.hIcon = g_icon;
|
||||
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
|
||||
if (!Shell_NotifyIconW(NIM_ADD, &nid))
|
||||
return false;
|
||||
|
||||
// Version 4 is what makes the callback carry the event in LOWORD(lParam)
|
||||
// and real screen coordinates in wParam. Without it the right-click
|
||||
// arrives as a bare WM_RBUTTONUP with no position and the menu opens in
|
||||
// the wrong place on a multi-monitor desktop.
|
||||
//
|
||||
// The result is CHECKED. wnd_proc decodes callbacks as version 4 and
|
||||
// nothing else, so a silent negotiation failure would leave a live icon
|
||||
// whose clicks and menu do nothing -- worse than no icon at all, because
|
||||
// close-to-tray would then hide the window behind it.
|
||||
NOTIFYICONDATAW ver = base_data();
|
||||
ver.uVersion = NOTIFYICON_VERSION_4;
|
||||
if (!Shell_NotifyIconW(NIM_SETVERSION, &ver)) {
|
||||
NOTIFYICONDATAW del = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &del);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void show_menu(int x, int y)
|
||||
{
|
||||
HMENU menu = CreatePopupMenu();
|
||||
if (!menu)
|
||||
return;
|
||||
|
||||
AppendMenuW(menu, MF_STRING, kMenuPlayPause, g_label_play_pause);
|
||||
AppendMenuW(menu, MF_STRING, kMenuPrevious, g_label_previous);
|
||||
AppendMenuW(menu, MF_STRING, kMenuNext, g_label_next);
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, kMenuShowHide, g_label_show_hide);
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, kMenuQuit, g_label_quit);
|
||||
|
||||
// The documented ritual. Without the SetForegroundWindow the menu does not
|
||||
// dismiss when the user clicks elsewhere; without the trailing PostMessage
|
||||
// it can stay up after a selection (MSDN TrackPopupMenu remarks).
|
||||
SetForegroundWindow(g_hwnd);
|
||||
const UINT cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY,
|
||||
x, y, 0, g_hwnd, nullptr);
|
||||
PostMessageW(g_hwnd, WM_NULL, 0, 0);
|
||||
DestroyMenu(menu);
|
||||
|
||||
// Hand keyboard focus back to the notification area, which the Shell
|
||||
// documentation asks for after a context menu closes -- particularly when
|
||||
// it was dismissed with Escape rather than a selection.
|
||||
NOTIFYICONDATAW focus = base_data();
|
||||
Shell_NotifyIconW(NIM_SETFOCUS, &focus);
|
||||
|
||||
switch (cmd) {
|
||||
case kMenuPlayPause:
|
||||
if (g_on_play_pause)
|
||||
g_on_play_pause();
|
||||
break;
|
||||
case kMenuNext:
|
||||
if (g_on_next)
|
||||
g_on_next();
|
||||
break;
|
||||
case kMenuPrevious:
|
||||
if (g_on_previous)
|
||||
g_on_previous();
|
||||
break;
|
||||
case kMenuShowHide:
|
||||
if (g_on_left_click)
|
||||
g_on_left_click();
|
||||
break;
|
||||
case kMenuQuit:
|
||||
if (g_on_quit)
|
||||
g_on_quit();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
||||
{
|
||||
// Explorer restarted (or crashed): every icon is gone from the new taskbar
|
||||
// and each app has to add its own back. Missing this is why a tray icon
|
||||
// "disappears forever" after an Explorer restart.
|
||||
if (msg == g_taskbar_created && g_taskbar_created != 0) {
|
||||
add_icon();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (msg == kTrayCallback) {
|
||||
// Version 4 packing: event in the low word of lParam, cursor position
|
||||
// in wParam.
|
||||
switch (LOWORD(lparam)) {
|
||||
case NIN_SELECT:
|
||||
case NIN_KEYSELECT:
|
||||
if (g_on_left_click)
|
||||
g_on_left_click();
|
||||
return 0;
|
||||
case WM_CONTEXTMENU:
|
||||
show_menu(GET_X_LPARAM(wparam), GET_Y_LPARAM(wparam));
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return DefWindowProcW(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
bool ensure_window()
|
||||
{
|
||||
if (g_hwnd)
|
||||
return true;
|
||||
|
||||
HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||
static const wchar_t *kClass = L"QbzTrayMessageWindow";
|
||||
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.lpfnWndProc = tray_wnd_proc;
|
||||
wc.hInstance = inst;
|
||||
wc.lpszClassName = kClass;
|
||||
// Re-registering the same class returns 0 with ERROR_CLASS_ALREADY_EXISTS,
|
||||
// which is fine on a second create.
|
||||
RegisterClassExW(&wc);
|
||||
|
||||
// NOT HWND_MESSAGE. A message-only window never receives the broadcast
|
||||
// TaskbarCreated, so the icon would never come back after an Explorer
|
||||
// restart. WS_EX_TOOLWINDOW keeps this invisible helper out of the taskbar
|
||||
// and the Alt+Tab list, which is the reason HWND_MESSAGE is tempting.
|
||||
g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kClass, L"QbzTrayMessageWindow",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, inst, nullptr);
|
||||
if (!g_hwnd)
|
||||
return false;
|
||||
|
||||
g_taskbar_created = RegisterWindowMessageW(L"TaskbarCreated");
|
||||
// A broadcast message is filtered out by UIPI unless the window opts in.
|
||||
if (g_taskbar_created)
|
||||
ChangeWindowMessageFilterEx(g_hwnd, g_taskbar_created, MSGFLT_ALLOW, nullptr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
|
||||
const wchar_t *previous, const wchar_t *show_hide,
|
||||
const wchar_t *quit)
|
||||
{
|
||||
copy_label(g_label_play_pause, 64, play_pause);
|
||||
copy_label(g_label_next, 64, next);
|
||||
copy_label(g_label_previous, 64, previous);
|
||||
copy_label(g_label_show_hide, 64, show_hide);
|
||||
copy_label(g_label_quit, 64, quit);
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
|
||||
void (*on_next)(), void (*on_previous)(),
|
||||
void (*on_quit)())
|
||||
{
|
||||
g_on_left_click = on_left_click;
|
||||
g_on_play_pause = on_play_pause;
|
||||
g_on_next = on_next;
|
||||
g_on_previous = on_previous;
|
||||
g_on_quit = on_quit;
|
||||
}
|
||||
|
||||
extern "C" bool qbz_win_tray_create(const wchar_t *tooltip)
|
||||
{
|
||||
if (tooltip)
|
||||
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
|
||||
|
||||
// Idempotent: a second create must not stack a second NIM_ADD on a live
|
||||
// icon (which fails and would report the whole tray dead while the first
|
||||
// icon is still sitting there). Remove the old one first.
|
||||
if (g_hwnd) {
|
||||
NOTIFYICONDATAW del = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &del);
|
||||
}
|
||||
|
||||
if (!ensure_window())
|
||||
return false;
|
||||
if (!g_icon)
|
||||
g_icon = load_tray_icon();
|
||||
|
||||
if (add_icon())
|
||||
return true;
|
||||
|
||||
// Failed. Give back the window and the icon rather than leaving them
|
||||
// parked for the life of the process: the Rust side will not retry, so
|
||||
// nothing else would ever free them.
|
||||
if (g_icon && g_icon_owned)
|
||||
DestroyIcon(g_icon);
|
||||
g_icon = nullptr;
|
||||
g_icon_owned = false;
|
||||
DestroyWindow(g_hwnd);
|
||||
g_hwnd = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_tooltip(const wchar_t *tooltip)
|
||||
{
|
||||
if (!tooltip)
|
||||
return;
|
||||
// szTip is 128 wchar INCLUDING the terminator; _TRUNCATE keeps the copy
|
||||
// safe and Windows simply elides the rest in the hover text.
|
||||
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
|
||||
if (!g_hwnd)
|
||||
return;
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
// NIF_SHOWTIP again: it is a per-call flag, not sticky state.
|
||||
nid.uFlags = NIF_TIP | NIF_SHOWTIP;
|
||||
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_set_playing(bool playing)
|
||||
{
|
||||
// Recorded for the menu; the label itself is the platform-neutral
|
||||
// "Play/Pause" the other two trays use, so nothing needs rebuilding here.
|
||||
g_playing = playing;
|
||||
}
|
||||
|
||||
extern "C" void qbz_win_tray_destroy(void)
|
||||
{
|
||||
if (!g_hwnd)
|
||||
return;
|
||||
NOTIFYICONDATAW nid = base_data();
|
||||
Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||
DestroyWindow(g_hwnd);
|
||||
g_hwnd = nullptr;
|
||||
// LoadImageW WITHOUT LR_SHARED returns an icon this process owns, and it
|
||||
// leaks unless destroyed. Only the LoadIconW(nullptr, ...) fallback is a
|
||||
// shared system icon, which must never be destroyed.
|
||||
if (g_icon && g_icon_owned)
|
||||
DestroyIcon(g_icon);
|
||||
g_icon = nullptr;
|
||||
g_icon_owned = false;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,40 @@
|
||||
// Windows notification-area icon. C ABI so the Rust seam (src/tray_windows.rs)
|
||||
// can drive it exactly as tray_linux.rs drives the StatusNotifierItem and
|
||||
// tray_macos.rs drives the NSStatusItem.
|
||||
//
|
||||
// EVERY function here is GUI-THREAD ONLY. The icon owns a real (hidden) window
|
||||
// and a message loop; touching it from tokio would post to a thread that never
|
||||
// pumps.
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <wchar.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// The five menu labels, already translated by the Rust side (the same
|
||||
// qbz_i18n keys Linux and macOS use, so the three menus stay identical).
|
||||
// Must be called before qbz_win_tray_create; the strings are copied.
|
||||
void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
|
||||
const wchar_t *previous, const wchar_t *show_hide,
|
||||
const wchar_t *quit);
|
||||
|
||||
// Installed once, before create. Each maps 1:1 to a tray_qt dispatcher.
|
||||
void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
|
||||
void (*on_next)(), void (*on_previous)(),
|
||||
void (*on_quit)());
|
||||
|
||||
// NIM_ADD + NIM_SETVERSION(4). False if the message window or the icon could
|
||||
// not be created -- the caller must then leave the tray handle empty, or
|
||||
// close-to-tray would hide the window with nothing to restore it from.
|
||||
bool qbz_win_tray_create(const wchar_t *tooltip);
|
||||
|
||||
void qbz_win_tray_set_tooltip(const wchar_t *tooltip);
|
||||
void qbz_win_tray_set_playing(bool playing);
|
||||
void qbz_win_tray_destroy(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ---------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in new issue
Block a user