feat(clipboard): Linux + Windows host clipboard backends as the pf-clipboard crate (Phase 1 host + Phase 3)
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Has been skipped
ci / docs-site (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m16s
ci / bench (pull_request) Successful in 6m8s
ci / rust (pull_request) Failing after 7m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 12m39s

The host half of the shared clipboard (design/clipboard-and-file-transfer.md §4),
ported from feat/shared-clipboard (6bd8c18b) into the post-W6 crate shape: the
backends land as a pf-clipboard subsystem crate (the pf-inject/pf-capture
pattern) instead of growing punktfunk-host back out, and the ~340-line
punktfunk1.rs integration is re-implemented against the native.rs/control.rs
split that replaced it.

pf-clipboard:
- host::wayland — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland).
- host::mutter — GNOME via Mutter's *direct* org.gnome.Mutter.RemoteDesktop
  clipboard (no data-control at any GNOME version; the xdg portal needs an
  interactive grant a headless host can't answer).
- host::windows + host::winfmt — Win32 clipboard on a hidden message-loop
  window: WM_CLIPBOARDUPDATE listener + OLE delayed rendering (WM_RENDERFORMAT)
  for text / CF_HTML / RTF / PNG.
- host::session — the backend-agnostic coordinator bridging HostClipboard to
  the QUIC clipboard plane (offers, fetch accept-loop, remote offers, pastes).
- A portable facade (policy / enabled / cap_advertised / ClipCoordCmd / start /
  spawn_decline_loop) so the orchestrator compiles cfg-free on every platform;
  ClipCoordCmd moves into the crate (it was host-owned before).

punktfunk-host glue:
- handshake.rs advertises HOST_CAP_CLIPBOARD via pf_clipboard::cap_advertised.
- serve_session starts the coordinator (gated on a real compositor — the
  synthetic source stays out of the session clipboard) and spawns the
  CLIP_FETCH_UNAVAILABLE decline loop when the policy is on but no backend bound.
- control.rs gains the ClipControl/ClipOffer arms + the host-offer forward
  branch, and the e2e session test (cap advertise → ClipState ack with
  BACKEND_UNAVAILABLE → fetch decline) rides in native.rs's tests.

Still opt-in default OFF (PUNKTFUNK_CLIPBOARD). Remaining: the macOS client
(design §5) — then this becomes user-visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:02:20 +02:00
parent 4ef90d586d
commit 391f8fb9f7
14 changed files with 3165 additions and 4 deletions
+664
View File
@@ -0,0 +1,664 @@
//! Host-side shared-clipboard backend for Windows (`design/clipboard-and-file-transfer.md` §4, Phase
//! 3). The Win32 clipboard is thread-affine and message-driven, so the whole backend lives on one
//! dedicated **message-loop thread** owning a hidden message-only window:
//!
//! * **host copy → client** — `AddClipboardFormatListener` delivers `WM_CLIPBOARDUPDATE`; we map the
//! available formats to wire MIMEs, cache them, and emit [`ClipEvent::Selection`]. Our own offers
//! are suppressed by the owner-check (we never forward what we ourselves put on the clipboard).
//! * **client fetch of the host clipboard** — a `Cmd::Read` reads the requested format's HGLOBAL and
//! converts it to wire bytes ([`super::winfmt`]).
//! * **client copy → host** — a `Cmd::SetOffer` installs the client's formats via OLE **delayed
//! rendering**: `SetClipboardData(fmt, NULL)`, so no bytes cross until a host app actually pastes.
//! * **host paste of client content** — the paste triggers `WM_RENDERFORMAT`; the message-loop thread
//! blocks (bounded) while the coordinator fetches the bytes from the client, then `SetClipboardData`s
//! them for the pasting app.
//!
//! The async coordinator drives the thread through a [`Cmd`] channel woken by `PostMessage(WM_APP_CMD)`
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Context as _;
use ::windows::core::{w, PCWSTR};
use ::windows::Win32::Foundation::{
GetLastError, GlobalFree, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM,
};
use ::windows::Win32::System::DataExchange::{
AddClipboardFormatListener, CloseClipboard, EmptyClipboard, GetClipboardData,
GetClipboardOwner, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW,
SetClipboardData,
};
use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
use ::windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
};
use ::windows::Win32::System::Ole::CF_UNICODETEXT;
use ::windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
TranslateMessage, GWLP_USERDATA, HWND_MESSAGE, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP,
WM_CLIPBOARDUPDATE, WM_DESTROY, WM_RENDERFORMAT, WNDCLASSW,
};
use super::winfmt;
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
const WM_APP_CMD: u32 = WM_APP + 1;
/// Upper bound the message-loop thread waits for the client's bytes during a `WM_RENDERFORMAT` paste.
/// The pasting app is frozen until we answer, so this caps how long a paste can hang; on expiry the
/// format is left unrendered (an empty paste) rather than blocking indefinitely.
const RENDER_TIMEOUT: Duration = Duration::from_secs(10);
/// `OpenClipboard` fails while another process transiently holds the clipboard (clipboard managers do
/// this constantly); retry briefly before giving up.
const OPEN_RETRIES: u32 = 20;
const OPEN_RETRY_DELAY: Duration = Duration::from_millis(5);
/// `RegisterClassW` returns this when the (process-global) class already exists — expected on the 2nd+
/// concurrent session, and not an error (we never unregister the class).
const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410;
type ClipTx = tokio::sync::mpsc::UnboundedSender<ClipEvent>;
/// A command from the async coordinator into the message-loop thread. Delivered over a tokio channel
/// and drained on `WM_APP_CMD`.
enum Cmd {
/// Install the client's wire MIMEs as a delayed-render host selection (empty ⇒ clear).
SetOffer(Vec<String>),
/// Drop the selection we own.
Clear,
/// Read one wire format of the current host selection for a client fetch.
Read {
wire: String,
resp: tokio::sync::oneshot::Sender<anyhow::Result<Vec<u8>>>,
},
/// Tear the window + thread down.
Shutdown,
}
/// Message-loop-thread-owned state, reached from the `WndProc` via `GWLP_USERDATA`. Only the message
/// thread ever dereferences it, so the `RefCell`s are sound (no cross-thread sharing); the fields the
/// async handle also touches (`current_wire`) are behind their own `Arc<Mutex>`.
struct WinClip {
/// Backend → coordinator events.
clip_tx: ClipTx,
/// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle.
current_wire: Arc<Mutex<Vec<String>>>,
/// Coordinator → backend commands, drained on `WM_APP_CMD`.
cmd_rx: RefCell<tokio::sync::mpsc::UnboundedReceiver<Cmd>>,
/// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`).
offered: RefCell<Vec<u32>>,
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
/// Our own message window — used for the owner-check and clipboard opens.
own_hwnd: HWND,
}
impl WinClip {
/// `WM_CLIPBOARDUPDATE`: a host app copied (or the clipboard was cleared). Suppress our own
/// delayed-render echoes via the owner-check, else announce the new wire MIMEs.
fn on_clipboard_update(&self, hwnd: HWND) {
// SAFETY: GetClipboardOwner has no preconditions and needs no open clipboard.
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
if owner.0 == hwnd.0 {
// Our own offer's echo (we own the clipboard) — not a host copy.
return;
}
let mimes = self.available_wire_mimes();
*self.current_wire.lock().unwrap() = mimes.clone();
let _ = self.clip_tx.send(ClipEvent::Selection { mimes });
}
/// The wire MIMEs the current clipboard advertises, in a stable order.
fn available_wire_mimes(&self) -> Vec<String> {
// SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard.
let avail = |fmt: u32| unsafe { IsClipboardFormatAvailable(fmt) }.is_ok();
let mut out = Vec::new();
if avail(CF_UNICODETEXT.0 as u32) {
out.push(WIRE_TEXT.to_string());
}
if avail(self.fmt_html) {
out.push(WIRE_HTML.to_string());
}
if avail(self.fmt_rtf) {
out.push(WIRE_RTF.to_string());
}
if avail(self.fmt_png) {
out.push(WIRE_PNG.to_string());
}
out
}
/// `WM_APP_CMD`: run every queued coordinator command on this thread. Drained into a `Vec` first so
/// the `cmd_rx` borrow is released before any command runs (defensive against re-entry).
fn drain_commands(&self, hwnd: HWND) {
let mut cmds = Vec::new();
{
let mut rx = self.cmd_rx.borrow_mut();
while let Ok(c) = rx.try_recv() {
cmds.push(c);
}
}
for c in cmds {
match c {
Cmd::SetOffer(wire) => self.apply_offer(hwnd, &wire),
Cmd::Clear => self.clear(hwnd),
Cmd::Read { wire, resp } => {
let _ = resp.send(self.read(&wire));
}
Cmd::Shutdown => {
// Drop our offer first so no WM_RENDERALLFORMATS fires as the window dies (we do
// NOT want the client's content to outlive the session on the host clipboard).
self.clear(hwnd);
// SAFETY: our own live window; triggers WM_DESTROY → PostQuitMessage → pump exit.
unsafe {
let _ = DestroyWindow(hwnd);
}
return;
}
}
}
}
/// Install the client's offer as a delayed-render host selection.
fn apply_offer(&self, hwnd: HWND, wire: &[String]) {
let fmts = self.formats_for_offer(wire);
if fmts.is_empty() {
self.clear(hwnd);
return;
}
if open_clipboard_retry(hwnd).is_err() {
tracing::debug!("clipboard: OpenClipboard for set_offer failed");
return;
}
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it); EmptyClipboard makes us the owner,
// then each SetClipboardData(_, None) registers a delayed-render promise for that format.
unsafe {
let _ = EmptyClipboard();
for &f in &fmts {
let _ = SetClipboardData(f, None);
}
}
*self.offered.borrow_mut() = fmts;
}
/// Drop the selection we own (empty the clipboard iff we're still its owner).
fn clear(&self, hwnd: HWND) {
let had = {
let mut o = self.offered.borrow_mut();
let was = !o.is_empty();
o.clear();
was
};
if !had {
return;
}
// SAFETY: GetClipboardOwner has no preconditions.
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
if owner.0 != hwnd.0 {
return; // someone else took the clipboard already
}
if open_clipboard_retry(hwnd).is_err() {
return;
}
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it); empty it to drop our promises.
unsafe {
let _ = EmptyClipboard();
}
}
/// Read one wire format of the current host selection (a client fetch).
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
let fmt = self
.format_for_wire(wire)
.context("unsupported wire MIME")?;
// If we own the clipboard, its content is our own delayed-render offer (the client's copy),
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
// SAFETY: GetClipboardOwner has no preconditions.
if unsafe { GetClipboardOwner() }.unwrap_or_default().0 == self.own_hwnd.0 {
anyhow::bail!("clipboard currently held by our own offer");
}
open_clipboard_retry(self.own_hwnd)?;
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it). GetClipboardData hands back a
// clipboard-owned HGLOBAL (we must NOT free it); GlobalLock/Size/Unlock are balanced and we
// copy exactly GlobalSize bytes out before the lock is released.
let raw = unsafe {
let handle = GetClipboardData(fmt).context("GetClipboardData")?;
let hg = HGLOBAL(handle.0);
let p = GlobalLock(hg);
if p.is_null() {
anyhow::bail!("GlobalLock failed");
}
let n = GlobalSize(hg);
let mut buf = vec![0u8; n];
std::ptr::copy_nonoverlapping(p as *const u8, buf.as_mut_ptr(), n);
let _ = GlobalUnlock(hg);
buf
};
Ok(convert_from_win(wire, &raw))
}
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else {
return;
};
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let ev = ClipEvent::Paste {
mime: wire.to_string(),
responder: PasteResponder::Sync(tx),
};
if self.clip_tx.send(ev).is_err() {
return; // coordinator gone
}
let bytes = match rx.recv_timeout(RENDER_TIMEOUT) {
Ok(b) => b,
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
};
let win_bytes = convert_to_win(wire, &bytes);
let Ok(hg) = alloc_hglobal(&win_bytes) else {
return;
};
// Do NOT OpenClipboard here — the pasting app already holds it open across WM_RENDERFORMAT.
// SAFETY: `hg` is a freshly-filled moveable HGLOBAL. On success the clipboard takes ownership
// (we must not free it); on failure ownership stays with us, so we free it.
unsafe {
if SetClipboardData(fmt, Some(HANDLE(hg.0))).is_err() {
let _ = GlobalFree(Some(hg));
}
}
}
/// The Win32 clipboard format id for a wire MIME (`None` = unsupported).
fn format_for_wire(&self, wire: &str) -> Option<u32> {
match wire {
WIRE_TEXT => Some(CF_UNICODETEXT.0 as u32),
WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png),
_ => None,
}
}
/// The wire MIME for a Win32 clipboard format id (`None` = one we don't offer).
fn wire_for_format(&self, fmt: u32) -> Option<&'static str> {
if fmt == CF_UNICODETEXT.0 as u32 {
Some(WIRE_TEXT)
} else if fmt == self.fmt_html {
Some(WIRE_HTML)
} else if fmt == self.fmt_rtf {
Some(WIRE_RTF)
} else if fmt == self.fmt_png {
Some(WIRE_PNG)
} else {
None
}
}
/// The clipboard format ids to promise for a client offer (dedup, 1:1 with the wire MIMEs — the OS
/// auto-synthesizes CF_TEXT/CF_OEMTEXT from CF_UNICODETEXT, so no manual text fan-out is needed).
fn formats_for_offer(&self, wire: &[String]) -> Vec<u32> {
let mut out = Vec::new();
for w in wire {
if let Some(f) = self.format_for_wire(w) {
if !out.contains(&f) {
out.push(f);
}
}
}
out
}
}
/// The active Windows clipboard backend handle held by [`super::HostClipboard`]. All Win32 work runs
/// on the message-loop thread; this is just the async-side control surface.
pub struct WindowsClipboard {
cmd_tx: tokio::sync::mpsc::UnboundedSender<Cmd>,
/// The message window's `HWND` as an `isize` (so the handle stays `Send`/`Sync`); rebuilt for the
/// `PostMessage` wakeups, which are documented thread-safe.
hwnd: isize,
current_wire: Arc<Mutex<Vec<String>>>,
join: Option<std::thread::JoinHandle<()>>,
}
impl WindowsClipboard {
/// Spin up the message-loop thread + hidden window and return once it has bound (or failed).
pub async fn open() -> anyhow::Result<(
WindowsClipboard,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::<ClipEvent>();
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Cmd>();
let current_wire = Arc::new(Mutex::new(Vec::new()));
// Register the three custom formats up front — process-global and thread-agnostic, so this is
// fine off the message thread and lets bring-up fail cleanly if the atoms can't be created.
let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?;
let fmt_png = register_format(w!("PNG"))?;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
let cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new()
.name("punktfunk-clipboard-win".into())
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
.context("spawn windows clipboard thread")?;
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
Ok(Ok(Ok(h))) => h,
Ok(Ok(Err(e))) => return Err(e),
Ok(Err(_)) => anyhow::bail!("windows clipboard thread exited during bring-up"),
Err(_) => anyhow::bail!("windows clipboard bring-up timed out"),
};
Ok((
WindowsClipboard {
cmd_tx,
hwnd,
current_wire,
join: Some(join),
},
clip_rx,
))
}
/// The current host selection's wire MIMEs (empty = nothing to offer).
pub fn current_wire_mimes(&self) -> Vec<String> {
self.current_wire.lock().unwrap().clone()
}
/// Install a client's offered formats as the host selection (fire-and-forget onto the thread).
pub fn set_offer(&self, wire_mimes: &[String]) {
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
self.wake();
}
/// Drop the host selection we own (fire-and-forget onto the thread).
pub fn clear_offer(&self) {
let _ = self.cmd_tx.send(Cmd::Clear);
self.wake();
}
/// Read one wire format of the current host selection (a client's fetch).
pub async fn read_current(&self, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
let (tx, rx) = tokio::sync::oneshot::channel();
self.cmd_tx
.send(Cmd::Read {
wire: wire_mime.to_string(),
resp: tx,
})
.map_err(|_| anyhow::anyhow!("clipboard thread gone"))?;
self.wake();
rx.await
.map_err(|_| anyhow::anyhow!("clipboard read dropped"))?
}
/// Poke the message loop so it drains the command channel.
fn wake(&self) {
// SAFETY: PostMessageW is documented thread-safe; `hwnd` is our message window (or already
// destroyed, in which case the post harmlessly fails and is ignored).
let _ = unsafe {
PostMessageW(
Some(HWND(self.hwnd as *mut core::ffi::c_void)),
WM_APP_CMD,
WPARAM(0),
LPARAM(0),
)
};
}
}
impl Drop for WindowsClipboard {
fn drop(&mut self) {
let _ = self.cmd_tx.send(Cmd::Shutdown);
self.wake();
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
/// RAII `CloseClipboard` guard — pairs with a successful `open_clipboard_retry`, closing on scope exit
/// (including early `?`/`bail!` returns).
struct ClipboardGuard;
impl Drop for ClipboardGuard {
fn drop(&mut self) {
// SAFETY: constructed only after a successful OpenClipboard on this thread.
unsafe {
let _ = CloseClipboard();
}
}
}
/// Register (or resolve the existing id of) a custom clipboard format.
fn register_format(name: PCWSTR) -> anyhow::Result<u32> {
// SAFETY: RegisterClipboardFormatW is thread-agnostic and process-global; `name` is a static
// NUL-terminated wide literal.
let id = unsafe { RegisterClipboardFormatW(name) };
if id == 0 {
anyhow::bail!("RegisterClipboardFormatW failed");
}
Ok(id)
}
/// Allocate a moveable HGLOBAL holding `bytes` (zero-init so an empty payload is still a valid, locked
/// buffer). Ownership is transferred to the clipboard by a following `SetClipboardData`.
fn alloc_hglobal(bytes: &[u8]) -> anyhow::Result<HGLOBAL> {
// SAFETY: allocate at least one byte (GlobalLock of a 0-size block is unreliable), lock it, copy
// the payload in, unlock. Alloc/lock/unlock are balanced; on lock failure we free before erroring.
unsafe {
let hg = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes.len().max(1))
.context("GlobalAlloc")?;
let p = GlobalLock(hg);
if p.is_null() {
let _ = GlobalFree(Some(hg));
anyhow::bail!("GlobalLock failed");
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), p as *mut u8, bytes.len());
let _ = GlobalUnlock(hg);
Ok(hg)
}
}
/// `OpenClipboard(hwnd)` with a brief retry loop (another process often holds it transiently).
fn open_clipboard_retry(hwnd: HWND) -> anyhow::Result<()> {
for _ in 0..OPEN_RETRIES {
// SAFETY: OpenClipboard with our window as owner; balanced by ClipboardGuard/CloseClipboard.
if unsafe { OpenClipboard(Some(hwnd)) }.is_ok() {
return Ok(());
}
std::thread::sleep(OPEN_RETRY_DELAY);
}
anyhow::bail!("OpenClipboard failed after retries")
}
/// Convert a Win32 clipboard payload to wire bytes.
fn convert_from_win(wire: &str, raw: &[u8]) -> Vec<u8> {
match wire {
WIRE_TEXT => winfmt::text_from_utf16(raw),
WIRE_HTML => winfmt::html_from_cf(raw),
WIRE_RTF => winfmt::rtf_from_cf(raw),
_ => raw.to_vec(), // PNG + anything else: verbatim
}
}
/// Convert wire bytes to a Win32 clipboard payload.
fn convert_to_win(wire: &str, wire_bytes: &[u8]) -> Vec<u8> {
match wire {
WIRE_TEXT => winfmt::text_to_utf16(wire_bytes),
WIRE_HTML => winfmt::html_to_cf(wire_bytes),
_ => wire_bytes.to_vec(), // RTF + PNG + anything else: verbatim
}
}
/// Create the hidden message-only window (registering the class once, process-wide).
fn create_window() -> anyhow::Result<HWND> {
// SAFETY: standard window-class registration + message-only window creation; every argument is a
// valid handle / static literal, and `wndproc` matches the WNDPROC ABI.
unsafe {
let hinstance: HINSTANCE = GetModuleHandleW(PCWSTR::null())
.context("GetModuleHandleW")?
.into();
let class_name = w!("PunktfunkClipboardWindow");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinstance,
lpszClassName: class_name,
..Default::default()
};
if RegisterClassW(&wc) == 0 {
let code = GetLastError();
if code.0 != ERROR_CLASS_ALREADY_EXISTS {
anyhow::bail!("RegisterClassW failed: {code:?}");
}
}
let hwnd = CreateWindowExW(
WINDOW_EX_STYLE(0),
class_name,
w!(""),
WINDOW_STYLE(0),
0,
0,
0,
0,
Some(HWND_MESSAGE),
None,
Some(hinstance),
None,
)
.context("CreateWindowExW")?;
Ok(hwnd)
}
}
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
fn pump_thread(
clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
current_wire: Arc<Mutex<Vec<String>>>,
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) {
let hwnd = match create_window() {
Ok(h) => h,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
// A clone that outlives the boxed state, so we can announce Closed after the pump ends.
let closed_tx = clip_tx.clone();
let state = Box::new(WinClip {
clip_tx,
current_wire,
cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()),
fmt_html,
fmt_rtf,
fmt_png,
own_hwnd: hwnd,
});
let ptr = Box::into_raw(state);
// SAFETY: stash the state pointer for the WndProc; the window was created on this thread and the
// pointer stays valid until we reclaim the Box after the pump exits.
unsafe {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, ptr as isize);
}
// Snapshot whatever is already on the host clipboard, so the first client `enable` announces it
// (AddClipboardFormatListener only delivers *subsequent* changes).
{
// SAFETY: `ptr` is the live state we just stored; only this thread dereferences it.
let st = unsafe { &*ptr };
*st.current_wire.lock().unwrap() = st.available_wire_mimes();
}
// SAFETY: `hwnd` is our live window; start receiving WM_CLIPBOARDUPDATE.
if let Err(e) = unsafe { AddClipboardFormatListener(hwnd) } {
// SAFETY: tear down the half-built window and reclaim the leaked state box.
unsafe {
let _ = DestroyWindow(hwnd);
drop(Box::from_raw(ptr));
}
let _ = ready_tx.send(Err(
anyhow::Error::new(e).context("AddClipboardFormatListener")
));
return;
}
let _ = ready_tx.send(Ok(hwnd.0 as isize));
// SAFETY: the standard Win32 message pump. GetMessageW returns >0 for a message, 0 for WM_QUIT,
// and -1 on error — `.0 > 0` exits on both 0 and -1.
unsafe {
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).0 > 0 {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
// Pump exited (window destroyed): reclaim the leaked state box. No WndProc runs after this point.
// SAFETY: `ptr` came from Box::into_raw above, is dereferenced only on this thread, and the
// message loop has ended so no further access occurs.
unsafe {
drop(Box::from_raw(ptr));
}
let _ = closed_tx.send(ClipEvent::Closed);
}
/// The window procedure. Reaches per-window state through `GWLP_USERDATA`; runs only on the message
/// thread. Registered as the class `WNDPROC` (a safe fn coerces to the `unsafe extern "system"` ABI).
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
// SAFETY: GWLP_USERDATA holds the `*const WinClip` stored right after window creation (0/null for
// the WM_(NC)CREATE messages that fire before that — handled by the null check below).
let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const WinClip;
if ptr.is_null() {
// SAFETY: default processing before our state pointer is attached.
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
}
// SAFETY: `ptr` is the live Box<WinClip> leaked in pump_thread, owned by this (the only) message
// thread and freed only after the pump exits; the WndProc is not re-entered for this window, so
// `&*ptr` is a valid shared borrow.
let st = unsafe { &*ptr };
match msg {
WM_CLIPBOARDUPDATE => {
st.on_clipboard_update(hwnd);
LRESULT(0)
}
WM_RENDERFORMAT => {
st.on_render_format(wparam.0 as u32);
LRESULT(0)
}
WM_APP_CMD => {
st.drain_commands(hwnd);
LRESULT(0)
}
WM_DESTROY => {
// SAFETY: ends the GetMessageW pump by posting WM_QUIT to this thread's queue.
unsafe {
PostQuitMessage(0);
}
LRESULT(0)
}
// SAFETY: default handling for every other message.
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}