fix(share): copy links through QClipboard, not arboard (#684)

Copy link failed on GNOME + Flatpak: arboard's Wayland path needs the
wlr/ext-data-control protocol, which Mutter does not implement, so it fell
back to X11 — reachable natively through XWayland, unreachable inside the
sandbox ("clipboard unavailable: X11 server connection timed out"). KWin
has the protocol, which is why the failure never reproduced on Plasma.

Replace it with a C++ shim (cxx/clipboard.cpp) that queues the write onto
the GUI thread and hands it to QClipboard: the base wl_data_device protocol
every compositor implements, no extra sandbox permission, and the offer
lives as long as the application (the #514 lifetime rule, without the
long-lived arboard instance). arboard is dropped from the crate.

Verified on Plasma/Wayland and on an Ubuntu GNOME/Wayland VM with DISPLAY
unset (the sandbox condition), where the copy now lands.

cc
This commit is contained in:
vicrodh committed 2026-09-11 13:53:27 -06:00
1 parent 8b1941d9ad
commit 6b81cfa84d
5 files changed
+66 -313

No files matched your search

+4 -11
View File
@@ -37,17 +37,10 @@ url = "2"
open = "5"
dirs = "5"
# Share links (src/share_qt.rs).
# Rust owns the clipboard on this side because the toast does: cxx-qt-lib 0.7
# ships no QClipboard binding, and QML cannot publish QbzShell.toastJson, so
# splitting the copy and its confirmation across the boundary would buy
# nothing.
#
# wayland-data-control: Wayland-native clipboard via the data-control
# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland
# is absent (#514 — without it arboard is X11-only and every copy fails on
# Wayland-only sessions).
arboard = { version = "3", features = ["wayland-data-control"] }
# Share links (src/share_qt.rs) copy through Qt's own QClipboard
# (cxx/clipboard.cpp) since #684: arboard's Wayland path needs the
# wlr/ext-data-control protocol, which Mutter lacks, and its X11 fallback is
# unreachable inside the Flatpak sandbox.
qbz-core = { path = "../qbz-core" }
qbz-app = { path = "../qbz-app" }
+2
View File
@@ -401,6 +401,8 @@ fn build_rhi_items() {
cc.file("cxx/win_shell.cpp");
println!("cargo:rerun-if-changed=cxx/app_lifecycle.cpp");
cc.file("cxx/app_lifecycle.cpp");
println!("cargo:rerun-if-changed=cxx/clipboard.cpp");
cc.file("cxx/clipboard.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");
+35
View File
@@ -0,0 +1,35 @@
// System clipboard through Qt (#684).
//
// The Rust side used arboard, whose Wayland path needs the wlr/ext-data-control
// protocol. KWin has it; Mutter (GNOME) does not, so arboard fell back to X11
// there — which works natively through XWayland and FAILS inside the Flatpak
// sandbox (no X11 socket): "clipboard unavailable: X11 server connection timed
// out". QClipboard speaks the base wl_data_device protocol every compositor
// implements, needs no extra sandbox permission, and the offer stays alive for
// as long as the application does (the #514 lifetime rule, for free).
//
// The clipboard is a GUI-thread object; the call is queued onto the
// application's thread so any caller (a cxx-qt invokable already on it, or a
// tokio worker) is fine.
#include <QtCore/QCoreApplication>
#include <QtCore/QMetaObject>
#include <QtCore/QString>
#include <QtGui/QClipboard>
#include <QtGui/QGuiApplication>
extern "C" void qbz_clipboard_set_text(const char *utf8, int len) {
QCoreApplication *core = QCoreApplication::instance();
if (!core || !qobject_cast<QGuiApplication *>(core)) {
return;
}
const QString text = QString::fromUtf8(utf8, len);
QMetaObject::invokeMethod(
core,
[text]() {
if (QClipboard *clipboard = QGuiApplication::clipboard()) {
clipboard->setText(text, QClipboard::Clipboard);
}
},
Qt::QueuedConnection);
}
+19 -51
View File
@@ -58,63 +58,31 @@ pub(crate) fn qobuz_label_url(label_id: &str) -> String {
format!("https://play.qobuz.com/label/{label_id}")
}
/// Long-lived clipboard instance. arboard ties the offer's lifetime to the
/// LAST live `Clipboard` object: dropping it destroys the X11 selection
/// window (contents survive only when a clipboard MANAGER accepts the
/// handoff — KDE ships one, stock GNOME/XFCE/Cinnamon do not) and ends the
/// Wayland offer with the same rule. The old create-per-copy pattern
/// therefore worked on KDE and silently lost the text everywhere else
/// (HiFi-wizard copy report, #514). One instance kept alive for the whole
/// process serves the offer like any normal app.
///
/// This is the #514 fix, verbatim from `share.rs:32-41` — it is NOT
/// boilerplate to be simplified into a local. A create-per-copy port would
/// pass every test run on KDE and lose the text everywhere else.
static CLIPBOARD: std::sync::OnceLock<std::sync::Mutex<Option<arboard::Clipboard>>> =
std::sync::OnceLock::new();
unsafe extern "C" {
fn qbz_clipboard_set_text(utf8: *const std::os::raw::c_char, len: i32);
}
/// Copy `text` to the system clipboard. Runs on a blocking thread —
/// clipboard backends (X11/Wayland) can block. `share.rs:43-70`.
/// Copy `text` to the system clipboard through Qt (`cxx/clipboard.cpp`).
///
/// The one adaptation vs the reference: in the Slint app the arm already runs
/// inside a tokio context, whereas a cxx-qt invokable runs on the **Qt event
/// loop thread**, where a bare `tokio::task::spawn_blocking` panics ("must be
/// called from the context of a Tokio runtime"). It therefore goes through
/// `crate::spawn` (main.rs) onto the process-global runtime first — the same
/// `spawn` + `spawn_blocking` sandwich `local_ephemeral.rs`, `browse_qt.rs`
/// and `ambient_qt.rs` already use.
/// #684: the arboard path before this needed `wlr/ext-data-control`, which
/// KWin has and Mutter does not; on GNOME it fell back to X11, which works
/// natively through XWayland and is unreachable inside the Flatpak sandbox —
/// every Copy link on GNOME + Flatpak logged "clipboard unavailable" and
/// copied nothing. QClipboard uses the base Wayland data-device protocol,
/// needs no sandbox permission, and QGuiApplication keeps the offer alive
/// for the life of the process (the #514 lifetime rule, without the
/// long-lived instance the arboard port had to carry).
///
/// Fire-and-forget by design: the caller never learns whether the copy
/// worked, which is why the toast at the call site is unconditional
/// (`main.rs:12758-12761` does the same).
/// (`main.rs:12758-12761` does the same). The C++ side queues the write onto
/// the GUI thread, so this is safe from a cxx-qt invokable or a worker.
pub(crate) fn copy_to_clipboard(text: String) {
crate::spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
let cell = CLIPBOARD.get_or_init(|| std::sync::Mutex::new(None));
let mut guard = match cell.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if guard.is_none() {
match arboard::Clipboard::new() {
Ok(c) => *guard = Some(c),
Err(e) => {
log::warn!("[qbz-qt] clipboard unavailable: {e}");
return;
}
}
}
if let Some(clipboard) = guard.as_mut() {
if let Err(e) = clipboard.set_text(text) {
log::warn!("[qbz-qt] clipboard set failed: {e}");
// Drop the instance so the next copy reconnects — the
// display connection may have gone away.
*guard = None;
}
}
})
.await;
});
let len = i32::try_from(text.len()).unwrap_or(i32::MAX);
// SAFETY: the pointer/length pair is read synchronously inside the call
// (the C++ side copies into a QString before queueing), so `text` may be
// dropped as soon as the call returns.
unsafe { qbz_clipboard_set_text(text.as_ptr().cast(), len) };
}
/// Artist header ⋯ → Share. `artist/ArtistPageView.slint:530-538` fires