feat(media-controls): SMTC with a real HWND, and a native cover path
Windows media keys never worked because SMTC never started. souvlaki's Windows
backend does `config.hwnd.expect(..)`, which PANICS rather than degrading, and
`MediaControls::new` runs inside a tokio task, so a missing handle aborted the
task instead of returning the Err the caller was written for. Phase A bowed out
before reaching it and logged "SMTC disabled: no window handle wired yet".
`spawn` now takes a `NativeWindow`, and `cxx/win_shell.cpp` supplies the
top-level HWND through `QGuiApplication::topLevelWindows()`. The guard stays:
a missing integration is a missing feature, a panic in a tokio task is a dead
session.
Windows reads the handle from a bounded retry on the GUI thread. Both things it
waits for -- the tray bridge's Qt thread and a shown window -- are "not yet",
never "not ever": `tray_bridge::ui` is a no-op until `QbzTray::boot` registers
the thread, and `QbzSession.boot()` runs first (Main.qml:254 vs :380).
NON-WINDOWS IS UNCHANGED, deliberately. An earlier draft routed every platform
through that hop and it was a Linux/macOS regression three ways: initialization
could be dropped outright before boot, 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 with no reason to.
Cover art needed its own fix. souvlaki 0.8.3 branches on
`url.starts_with("file://")`, then trims exactly that prefix and hands the rest
to `GetFileFromPathAsync`. So `file:///C:/x.jpg` arrives as `/C:/x.jpg` -- a
slash before a drive letter, which never opens -- while a bare `C:\x.jpg` no
longer matches the prefix and takes the remote-URI branch instead. The value
that works is the hybrid `file://C:\x.jpg`. UNC is handled too: `file://nas/m/a`
is a real Windows path and was being passed through as the relative `nas/m/a`.
The percent-decode is full and applied once: `art_url` arrives from
`url::Url::from_file_path` (space as %20) and from `fs_url::file_url` (only
% # ? escaped), and one full decode reads both -- `%2520` gives back a literal
`%20` rather than a space. `fs_url::local_path` is deliberately not used here;
its three-escape decode would leave the other producer's %20 intact.
Eight tests cover the matrix. Reviewed adversarially before commit, which is
where the inverted cover conversion and the non-Windows regression were caught;
the souvlaki branch was then read at the source to confirm.
cc
This commit is contained in:
10 files changed
+397
-42
No files matched your search
Generated
+3
@@ -4367,10 +4367,12 @@ dependencies = [
|
||||
"md-5",
|
||||
"mpris-server",
|
||||
"notify-rust",
|
||||
"qbz-models",
|
||||
"reqwest",
|
||||
"souvlaki",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4581,6 +4583,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid 1.22.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winresource",
|
||||
"zbus 5.15.0",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,10 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
# fs_url::local_path -- the ONE file-URL helper (W2). SMTC needs a native
|
||||
# Windows path where MPRIS needs a file:// URL, and hand-rolling that
|
||||
# conversion is exactly the bug class that helper exists to prevent.
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
# Notifications (all platforms): the async entry point spawns the HTTP download
|
||||
# + image resize on `spawn_blocking`, so a tokio runtime must be present.
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -33,22 +33,42 @@ mod platform;
|
||||
/// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is
|
||||
/// safe to call from any thread, but the run loop (Slint's winit loop) must be
|
||||
/// running for inbound events to arrive.
|
||||
/// The platform window identity the OS integration needs.
|
||||
///
|
||||
/// Windows SMTC is registered against a window: souvlaki's backend calls
|
||||
/// `config.hwnd.expect(..)`, so a missing handle is a PANIC, not a graceful
|
||||
/// degrade. Linux and macOS key off the D-Bus name and the app bundle
|
||||
/// respectively and ignore this entirely, which is why `default()` is the
|
||||
/// right thing to pass there and from headless callers.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NativeWindow {
|
||||
pub hwnd: Option<*mut core::ffi::c_void>,
|
||||
}
|
||||
|
||||
// SAFETY: an HWND is an opaque process-wide identifier, not a pointer that is
|
||||
// dereferenced here. It is read once by souvlaki on the thread that receives
|
||||
// it. Windows itself allows a window handle to be named from any thread; what
|
||||
// is thread-affine is the window's message loop, which this never touches.
|
||||
unsafe impl Send for NativeWindow {}
|
||||
|
||||
pub fn spawn(
|
||||
on_event: impl Fn(MediaEvent) + Send + Sync + 'static,
|
||||
native: NativeWindow,
|
||||
) -> Option<Box<dyn MediaIntegration>> {
|
||||
let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = native;
|
||||
return linux::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
|
||||
}
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
return platform::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
|
||||
return platform::spawn(cb, native).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = cb;
|
||||
let _ = (cb, native);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,12 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition};
|
||||
// Only `spawn_native` and `map_event` touch these, and both are compiled out
|
||||
// on Windows (souvlaki's SMTC backend panics without an HWND — see `spawn`).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use souvlaki::{MediaControlEvent, PlatformConfig, SeekDirection};
|
||||
|
||||
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||
use crate::NativeWindow;
|
||||
|
||||
/// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros).
|
||||
// Only `map_event` reads it, and that is compiled out on Windows.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const SEEK_STEP_MICROS: i64 = 5_000_000;
|
||||
|
||||
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
|
||||
@@ -31,12 +27,19 @@ impl MediaIntegration for PlatformHandle {
|
||||
fn set_metadata(&self, meta: &TrackMeta) {
|
||||
if let Ok(mut guard) = self.controls.lock() {
|
||||
if let Some(c) = guard.as_mut() {
|
||||
// SMTC wants a NATIVE path where MPRIS wants a URL; see
|
||||
// `cover_for_souvlaki`. Bound to a local so the &str borrowed
|
||||
// into MediaMetadata outlives the call.
|
||||
let cover = meta
|
||||
.art_url
|
||||
.as_deref()
|
||||
.map(|u| cover_for_souvlaki(u, cfg!(target_os = "windows")));
|
||||
let md = MediaMetadata {
|
||||
title: Some(meta.title.as_str()),
|
||||
artist: Some(meta.artist.as_str()),
|
||||
album: Some(meta.album.as_str()),
|
||||
duration: meta.duration,
|
||||
cover_url: meta.art_url.as_deref(),
|
||||
cover_url: cover.as_deref(),
|
||||
};
|
||||
let _ = c.set_metadata(md);
|
||||
}
|
||||
@@ -63,8 +66,6 @@ impl MediaIntegration for PlatformHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// Only `spawn_native` installs it, and that is compiled out on Windows.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
|
||||
Some(match e {
|
||||
MediaControlEvent::Play => MediaEvent::Play,
|
||||
@@ -90,34 +91,96 @@ fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
|
||||
// souvlaki's Windows backend does NOT degrade gracefully without an HWND:
|
||||
// `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59
|
||||
// PANICS, and `MediaControls::new` runs inside a tokio task, so the first
|
||||
// session would abort the task rather than return the Err the match below
|
||||
// is written for. (The old comment on `hwnd: None` claimed the opposite;
|
||||
// it was read from the comment, not the mechanism.) No HWND is wired yet
|
||||
// — that is plan A-2's "SMTC real" task — so bow out here on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = on_event;
|
||||
log::warn!("[media-controls] SMTC disabled: no window handle wired yet (Phase A)");
|
||||
return None;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
return spawn_native(on_event);
|
||||
/// Convert a cover-art URL into the exact string souvlaki's Windows arm can
|
||||
/// open.
|
||||
///
|
||||
/// souvlaki 0.8.3 (`src/platform/windows/mod.rs:192-203`) branches like this:
|
||||
///
|
||||
/// ```text
|
||||
/// if url.starts_with("file://") {
|
||||
/// let path = url.trim_start_matches("file://");
|
||||
/// StorageFile::GetFileFromPathAsync(path) // wants C:\x or \\nas\x
|
||||
/// } else {
|
||||
/// RandomAccessStreamReference::CreateFromUri(url)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// So `file://` is the DISCRIMINATOR and must survive, while everything after
|
||||
/// it has to be a native Windows path. Two ways to get this wrong, and the
|
||||
/// first draft of this function managed one of them:
|
||||
///
|
||||
/// - Hand over `file:///C:/x.jpg` and souvlaki trims seven characters, leaving
|
||||
/// `/C:/x.jpg` -- a leading slash before a drive letter, which never opens.
|
||||
/// - Hand over a bare `C:\x.jpg` and it no longer starts with `file://`, so it
|
||||
/// takes the REMOTE branch and `Uri::CreateUri` fails on it.
|
||||
///
|
||||
/// The answer is the hybrid `file://C:\x.jpg`: prefix kept, remainder native.
|
||||
///
|
||||
/// UNC is a real Windows path too. `file://nas/music/a.jpg` names
|
||||
/// `\\nas\music\a.jpg`, and leaving it alone would hand souvlaki the
|
||||
/// relative `nas/music/a.jpg`.
|
||||
///
|
||||
/// The percent-decode is FULL and applied ONCE. `art_url` reaches this from
|
||||
/// two producers -- `url::Url::from_file_path`, which escapes a space as
|
||||
/// `%20`, and `fs_url::file_url`, which escapes only `%`, `#` and `?` -- and a
|
||||
/// single full decode is correct for both. `fs_url::local_path` is NOT used
|
||||
/// here for that reason: its deliberate three-escape decode would leave a
|
||||
/// `%20` from the other producer intact.
|
||||
pub(crate) fn cover_for_souvlaki(url: &str, windows: bool) -> String {
|
||||
if !windows {
|
||||
return url.to_string();
|
||||
}
|
||||
let Some(rest) = url.strip_prefix("file://") else {
|
||||
// http(s) and anything else: souvlaki's Uri branch is the right one.
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
let decoded = urlencoding::decode(rest)
|
||||
.map(|c| c.into_owned())
|
||||
.unwrap_or_else(|_| rest.to_string());
|
||||
let b = decoded.as_bytes();
|
||||
|
||||
let native = if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
|
||||
// `file:///C:/x` -> the empty authority's slash sits in front of the
|
||||
// drive letter; drop it.
|
||||
decoded[1..].to_string()
|
||||
} else if !decoded.starts_with('/') && !decoded.is_empty() {
|
||||
// A non-empty authority is a UNC server: `file://nas/share/x`.
|
||||
format!("//{decoded}")
|
||||
} else {
|
||||
// A POSIX absolute path. Not a Windows path at all, and nothing here
|
||||
// can make it one -- hand it back untouched rather than inventing
|
||||
// `\home\...`.
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
format!("file://{}", native.replace('/', "\\"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn spawn_native(on_event: EventCb) -> Option<PlatformHandle> {
|
||||
pub fn spawn(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
|
||||
// souvlaki's Windows backend does NOT degrade gracefully without an HWND:
|
||||
// `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59
|
||||
// PANICS. The guard therefore stays even now that a handle IS wired:
|
||||
// a missing integration is a missing feature, a panic inside a tokio task
|
||||
// is a dead session. The caller reads the HWND on the GUI thread after the
|
||||
// window is shown, so None here means it asked too early.
|
||||
#[cfg(target_os = "windows")]
|
||||
if native.hwnd.is_none() {
|
||||
log::warn!(
|
||||
"[media-controls] SMTC disabled: no top-level window yet (init ran before it was shown)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
spawn_native(on_event, native)
|
||||
}
|
||||
|
||||
fn spawn_native(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "com.blitzfc.qbz",
|
||||
display_name: "QBZ",
|
||||
// macOS: unused. Windows: unreachable — see the early return in
|
||||
// `spawn` above.
|
||||
hwnd: None,
|
||||
// macOS ignores it; Windows registers SMTC against this window.
|
||||
hwnd: native.hwnd,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
@@ -142,3 +205,90 @@ fn spawn_native(on_event: EventCb) -> Option<PlatformHandle> {
|
||||
controls: Arc::new(Mutex::new(Some(controls))),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cover_for_souvlaki;
|
||||
|
||||
#[test]
|
||||
fn windows_keeps_the_file_prefix_and_makes_the_rest_native() {
|
||||
// The prefix is souvlaki's discriminator: drop it and the string takes
|
||||
// the remote-URI branch. Keep it, and what it trims must be a real
|
||||
// Windows path.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/Users/v/c.jpg", true),
|
||||
"file://C:\\Users\\v\\c.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_windows_is_byte_identical() {
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///home/v/c.jpg", false),
|
||||
"file:///home/v/c.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/Users/v/c.jpg", false),
|
||||
"file:///C:/Users/v/c.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_urls_are_never_touched() {
|
||||
assert_eq!(cover_for_souvlaki("https://x/y.jpg", true), "https://x/y.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unc_url_becomes_a_unc_path() {
|
||||
// `file://nas/music/a.jpg` is a real Windows path. Left alone, souvlaki
|
||||
// would trim it to the RELATIVE `nas/music/a.jpg`.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file://nas/music/a.jpg", true),
|
||||
"file://\\\\nas\\music\\a.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_posix_path_is_left_alone_even_on_windows() {
|
||||
// No drive, no authority: nothing here can turn it into a Windows path.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///home/v/c.jpg", true),
|
||||
"file:///home/v/c.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_producers_percent_decode_the_same_way() {
|
||||
// fs_url::file_url leaves a space LITERAL...
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/My Music/a b.jpg", true),
|
||||
"file://C:\\My Music\\a b.jpg"
|
||||
);
|
||||
// ...while url::Url::from_file_path escapes it as %20. One full decode
|
||||
// is right for both.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/My%20Music/a.jpg", true),
|
||||
"file://C:\\My Music\\a.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_escapes_that_matter_are_undone() {
|
||||
// A # in a filename reaches SMTC as %23 otherwise, and the cover
|
||||
// silently never loads.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/m/a%23b%3Fc.jpg", true),
|
||||
"file://C:\\m\\a#b?c.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_percent_twenty_is_not_double_decoded() {
|
||||
// A filename that really contains "%20" is escaped to %2520 by either
|
||||
// producer; ONE decode must give back "%20", not a space.
|
||||
assert_eq!(
|
||||
cover_for_souvlaki("file:///C:/m/a%2520b.jpg", true),
|
||||
"file://C:\\m\\a%20b.jpg"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,8 @@ fn build_rhi_items() {
|
||||
// font through QFontDatabase before Main.qml constructs any text.
|
||||
println!("cargo:rerun-if-changed=cxx/font_fallback.cpp");
|
||||
cc.file("cxx/font_fallback.cpp");
|
||||
println!("cargo:rerun-if-changed=cxx/win_shell.cpp");
|
||||
cc.file("cxx/win_shell.cpp");
|
||||
for p in qtbuild.include_paths() {
|
||||
cc.include(p);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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>
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -392,6 +392,9 @@ 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;
|
||||
// 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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
@@ -81,11 +81,17 @@ pub fn spawn(
|
||||
// integration never keeps the runtime alive.
|
||||
let weak: Weak<AppRuntime<DaemonAdapter>> = Arc::downgrade(runtime);
|
||||
let cb_handle = handle.clone();
|
||||
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(move |ev| {
|
||||
if let Some(rt) = weak.upgrade() {
|
||||
handle_media_event(&rt, &roots, &cb_handle, ev);
|
||||
}
|
||||
})?);
|
||||
// Headless: the daemon owns no window, so there is no HWND to give.
|
||||
// On Linux, which is the only place qbzd ships, MPRIS keys off the D-Bus
|
||||
// name and ignores this entirely.
|
||||
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(
|
||||
move |ev| {
|
||||
if let Some(rt) = weak.upgrade() {
|
||||
handle_media_event(&rt, &roots, &cb_handle, ev);
|
||||
}
|
||||
},
|
||||
qbz_media_controls::NativeWindow::default(),
|
||||
)?);
|
||||
log::info!("[mpris] publishing org.mpris.MediaPlayer2 (desktop media controls + media keys)");
|
||||
|
||||
// OUTBOUND: seed once from live state, then follow the bus.
|
||||
|
||||
Reference in new issue
Block a user