Crafter.Graphics/implementations/Crafter.Graphics-Clipboard.cpp
2026-05-19 00:45:22 +02:00

442 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include <wayland-client.h>
#include <wayland-client-protocol.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <fcntl.h>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#endif
module Crafter.Graphics;
import std;
using namespace Crafter;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
namespace {
// ─── write path ──────────────────────────────────────────────────
//
// Heap-allocated state attached to each wl_data_source via its
// user_data slot. The text must outlive the source (the compositor
// can call `send` minutes after we set the selection, every time a
// remote app pastes), so we own it here and free it from
// `OnSourceCancelled` — which is the only callback the compositor
// guarantees will fire when the source is no longer needed (when
// the selection is replaced, or on app exit).
struct Held {
std::string text;
};
void OnSourceTarget(void*, wl_data_source*, const char*) {}
void OnSourceSend(void* data, wl_data_source*,
const char* /*mime_type*/, std::int32_t fd) {
// We only ever advertise text MIME types, so any negotiated
// type maps to the same UTF-8 buffer. `send` may fire multiple
// times across the lifetime of one selection (each paste is a
// fresh fd), so we must not consume `text` here.
Held* h = static_cast<Held*>(data);
const char* p = h->text.data();
std::size_t rem = h->text.size();
while (rem > 0) {
const ssize_t w = ::write(fd, p, rem);
if (w < 0) {
if (errno == EINTR) continue;
break; // pipe closed; remote gave up reading
}
if (w == 0) break;
p += w;
rem -= static_cast<std::size_t>(w);
}
::close(fd);
}
void OnSourceCancelled(void* data, wl_data_source* source) {
// Selection was replaced (someone else copied) or the app is
// exiting. Either way, this is our hook to release the held
// text and the source itself.
delete static_cast<Held*>(data);
wl_data_source_destroy(source);
}
// Drag-and-drop only callbacks. We never start a DnD action, so
// these can't fire — but the listener struct's size is fixed at
// the v3 shape, and wayland-client uses the struct's slots
// directly. Stubs are required.
void OnSourceDndDropPerformed(void*, wl_data_source*) {}
void OnSourceDndFinished(void*, wl_data_source*) {}
void OnSourceAction(void*, wl_data_source*, std::uint32_t) {}
constexpr wl_data_source_listener kSourceListener = {
.target = OnSourceTarget,
.send = OnSourceSend,
.cancelled = OnSourceCancelled,
.dnd_drop_performed = OnSourceDndDropPerformed,
.dnd_finished = OnSourceDndFinished,
.action = OnSourceAction,
};
// ─── read path ───────────────────────────────────────────────────
//
// The compositor announces every clipboard selection (and every
// drag-and-drop) via a `wl_data_offer` we have to register a
// listener on to learn its advertised mime types. The lifecycle:
//
// 1. wl_data_device.data_offer → fresh wl_data_offer ID. We
// attach an offer listener so we receive the mime list.
// 2. wl_data_offer.offer (N×) → one event per advertised mime.
// 3. wl_data_device.selection → the offer becomes "the
// clipboard". Replaces (and invalidates) the previous one.
// An offer parameter of nullptr means the clipboard was
// cleared.
//
// DnD offers travel through the same data_offer event and end up
// delivered via `enter`/`leave`/`drop`. We don't implement DnD,
// but we still need to destroy those offers so we don't leak the
// mime vector attached to them — we stash the most recent DnD
// offer on enter and destroy it on leave/drop.
struct OfferState {
std::vector<std::string> mimes;
};
// Map keyed on raw wl_data_offer pointer. The compositor hands the
// same pointer back in subsequent events, so identity is stable.
std::unordered_map<wl_data_offer*, OfferState> g_offers;
wl_data_offer* g_selectionOffer = nullptr;
wl_data_offer* g_dndOffer = nullptr;
bool g_listenerAttached = false;
void DestroyOffer(wl_data_offer* offer) {
if (offer == nullptr) return;
g_offers.erase(offer);
wl_data_offer_destroy(offer);
}
void OnOfferOffer(void*, wl_data_offer* offer, const char* mime) {
auto it = g_offers.find(offer);
if (it != g_offers.end()) it->second.mimes.emplace_back(mime);
}
void OnOfferSourceActions(void*, wl_data_offer*, std::uint32_t) {}
void OnOfferAction(void*, wl_data_offer*, std::uint32_t) {}
constexpr wl_data_offer_listener kOfferListener = {
.offer = OnOfferOffer,
.source_actions = OnOfferSourceActions,
.action = OnOfferAction,
};
void OnDeviceDataOffer(void*, wl_data_device*, wl_data_offer* offer) {
// First event in the sequence — register so we hear the mime list.
// The offer can still end up unused (DnD that doesn't enter our
// surface) but we always destroy it later in selection or
// leave/drop.
g_offers.emplace(offer, OfferState{});
wl_data_offer_add_listener(offer, &kOfferListener, nullptr);
}
void OnDeviceEnter(void*, wl_data_device*, std::uint32_t,
wl_surface*, wl_fixed_t, wl_fixed_t,
wl_data_offer* offer) {
// DnD started over our surface. We never accept, so we just
// hold the offer until leave/drop and free it then.
g_dndOffer = offer;
}
void OnDeviceLeave(void*, wl_data_device*) {
DestroyOffer(g_dndOffer);
g_dndOffer = nullptr;
}
void OnDeviceMotion(void*, wl_data_device*, std::uint32_t,
wl_fixed_t, wl_fixed_t) {}
void OnDeviceDrop(void*, wl_data_device*) {
DestroyOffer(g_dndOffer);
g_dndOffer = nullptr;
}
void OnDeviceSelection(void*, wl_data_device*, wl_data_offer* offer) {
// Selection changed; previous selection offer is invalid.
// `offer == nullptr` means the clipboard was cleared.
DestroyOffer(g_selectionOffer);
g_selectionOffer = offer;
}
constexpr wl_data_device_listener kDeviceListener = {
.data_offer = OnDeviceDataOffer,
.enter = OnDeviceEnter,
.leave = OnDeviceLeave,
.motion = OnDeviceMotion,
.drop = OnDeviceDrop,
.selection = OnDeviceSelection,
};
const char* PickTextMime(const OfferState& s) {
// Same priority order we advertise from SetText, so a round-trip
// through ourselves picks the richest mime first.
constexpr std::array kWantedMimes = {
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"TEXT",
};
for (const char* want : kWantedMimes) {
for (const auto& m : s.mimes) {
if (m == want) return want;
}
}
return nullptr;
}
}
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
// Single JS import — the env.js implementation calls navigator.clipboard.writeText.
// `bool` return mirrors the native paths so callers don't need to #ifdef the
// check; failure cases (no permission, async rejection) just log to console.
// Lives in a named namespace (anonymous would give internal linkage and the
// import_module attribute would be dropped at link time).
namespace Crafter::DomEnv {
__attribute__((import_module("env"), import_name("clipboardSetText")))
bool DomClipboardSetText(const char* text, std::int32_t len);
// JS returns either 0 (no text latched) or a wasm-malloc'd NUL-
// terminated UTF-8 pointer that we own and free. Matches the
// existing `getValue` ABI used by HtmlElementPtr::GetValue.
__attribute__((import_module("env"), import_name("clipboardGetText")))
const char* DomClipboardGetText();
}
#endif
void Crafter::Clipboard::Initialize() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (g_listenerAttached) return;
if (Device::dataDevice == nullptr) return; // compositor doesn't expose it
wl_data_device_add_listener(Device::dataDevice, &kDeviceListener, nullptr);
g_listenerAttached = true;
#endif
}
bool Crafter::Clipboard::SetText(std::string_view text) {
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
return Crafter::DomEnv::DomClipboardSetText(text.data(), static_cast<std::int32_t>(text.size()));
#elif defined(CRAFTER_GRAPHICS_WINDOW_WAYLAND)
if (Device::dataDeviceManager == nullptr || Device::dataDevice == nullptr) {
// Compositor doesn't expose wl_data_device_manager (rare; some
// headless / minimal compositors). Caller can fall back.
return false;
}
// wl_data_device.set_selection requires a serial from a recent
// input event. We track the most recent pointer-enter serial on
// each window — fine for "user clicked Copy" interactions, since
// the click itself produced the serial. Without one, the
// compositor would silently reject the request.
std::uint32_t serial = 0;
if (Device::focusedWindow != nullptr) {
serial = Device::focusedWindow->lastPointerSerial_;
}
if (serial == 0) return false;
auto* held = new Held{ std::string(text) };
wl_data_source* source =
wl_data_device_manager_create_data_source(Device::dataDeviceManager);
if (source == nullptr) {
delete held;
return false;
}
wl_data_source_add_listener(source, &kSourceListener, held);
// Advertise the four common text MIME types so legacy + modern
// pasters both find a match. Wayland clients prefer the first one
// they recognise, in order.
wl_data_source_offer(source, "text/plain;charset=utf-8");
wl_data_source_offer(source, "text/plain");
wl_data_source_offer(source, "UTF8_STRING");
wl_data_source_offer(source, "TEXT");
wl_data_device_set_selection(Device::dataDevice, source, serial);
// Push the request so the compositor sees it before the next event
// loop iteration — otherwise a quick read in another app might
// miss the selection update.
wl_display_flush(Device::display);
return true;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WIN32)
// CF_UNICODETEXT round-trip. Convert UTF-8 → UTF-16, allocate a
// moveable HGLOBAL, hand it off to the OS. The OS frees the global
// when the next clipboard owner replaces the data, so our caller
// doesn't need to keep `text` alive — same lifetime contract as
// the Wayland path.
if (!OpenClipboard(nullptr)) return false;
EmptyClipboard();
const int wlen = MultiByteToWideChar(CP_UTF8, 0,
text.data(), static_cast<int>(text.size()), nullptr, 0);
if (wlen < 0) { CloseClipboard(); return false; }
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE,
(static_cast<std::size_t>(wlen) + 1) * sizeof(wchar_t));
if (h == nullptr) { CloseClipboard(); return false; }
wchar_t* dst = static_cast<wchar_t*>(GlobalLock(h));
if (dst == nullptr) {
GlobalFree(h);
CloseClipboard();
return false;
}
MultiByteToWideChar(CP_UTF8, 0,
text.data(), static_cast<int>(text.size()), dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
const bool ok = SetClipboardData(CF_UNICODETEXT, h) != nullptr;
if (!ok) GlobalFree(h); // OS only takes ownership on success
CloseClipboard();
return ok;
#else
(void)text;
return false;
#endif
}
std::optional<std::string> Crafter::Clipboard::GetText() {
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
const char* raw = Crafter::DomEnv::DomClipboardGetText();
if (raw == nullptr) return std::nullopt;
std::string out(raw);
std::free(const_cast<char*>(raw)); // JS side allocated via WasmAlloc/malloc
return out;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WAYLAND)
if (g_selectionOffer == nullptr) return std::nullopt;
auto it = g_offers.find(g_selectionOffer);
if (it == g_offers.end()) return std::nullopt;
const char* mime = PickTextMime(it->second);
if (mime == nullptr) return std::nullopt; // selection holds no text mime
int fds[2];
if (::pipe(fds) != 0) return std::nullopt;
// Read end stays blocking; we drive it with poll() below.
wl_data_offer_receive(g_selectionOffer, mime, fds[1]);
// Flush so the compositor forwards `receive` to the source app.
wl_display_flush(Device::display);
// Drop our copy of the write end. The source app's copy stays open
// until it finishes writing; once it closes, our read sees EOF.
::close(fds[1]);
std::string out;
const int wlFd = wl_display_get_fd(Device::display);
// One-second budget. The poll loop also drives the Wayland event
// pump so that if *we* are the source of the selection, our own
// data_source.send callback can run and write into the pipe — a
// plain blocking read on the pipe would deadlock in that case.
constexpr int kTimeoutMs = 1000;
auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(kTimeoutMs);
bool done = false;
while (!done) {
// Prepare for a non-blocking read on the wayland fd. The
// prepare/read pair lets us safely interleave with poll().
while (wl_display_prepare_read(Device::display) != 0) {
wl_display_dispatch_pending(Device::display);
}
wl_display_flush(Device::display);
const auto now = std::chrono::steady_clock::now();
int remaining = static_cast<int>(
std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - now).count());
if (remaining < 0) remaining = 0;
pollfd pfds[2] = {
{ fds[0], POLLIN, 0 },
{ wlFd, POLLIN, 0 },
};
const int n = ::poll(pfds, 2, remaining);
if (n < 0) {
wl_display_cancel_read(Device::display);
if (errno == EINTR) continue;
break;
}
if (n == 0) {
wl_display_cancel_read(Device::display);
break; // timeout
}
if (pfds[1].revents & POLLIN) {
wl_display_read_events(Device::display);
wl_display_dispatch_pending(Device::display);
} else {
wl_display_cancel_read(Device::display);
}
if (pfds[0].revents & (POLLIN | POLLHUP)) {
char buf[4096];
while (true) {
const ssize_t r = ::read(fds[0], buf, sizeof(buf));
if (r > 0) {
out.append(buf, static_cast<std::size_t>(r));
continue;
}
if (r == 0) { done = true; break; } // EOF
if (errno == EINTR) continue;
if (errno == EAGAIN) break; // shouldn't happen (blocking), but harmless
done = true;
break;
}
}
if (pfds[0].revents & POLLERR) {
break;
}
}
::close(fds[0]);
if (out.empty()) return std::nullopt;
return out;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WIN32)
// OS owns the wide-char buffer until we CloseClipboard, so the
// copy-out into std::string must happen before we release the lock.
if (!OpenClipboard(nullptr)) return std::nullopt;
HANDLE h = GetClipboardData(CF_UNICODETEXT);
if (h == nullptr) { CloseClipboard(); return std::nullopt; }
const wchar_t* src = static_cast<const wchar_t*>(GlobalLock(h));
if (src == nullptr) { CloseClipboard(); return std::nullopt; }
// CF_UNICODETEXT is NUL-terminated; lstrlenW gives us the length
// without the terminator. WideCharToMultiByte with length excluding
// the terminator produces a length-prefixed UTF-8 buffer we can
// size std::string for exactly.
const int wlen = lstrlenW(src);
std::string out;
if (wlen > 0) {
const int u8len = WideCharToMultiByte(CP_UTF8, 0, src, wlen,
nullptr, 0, nullptr, nullptr);
if (u8len > 0) {
out.resize(static_cast<std::size_t>(u8len));
WideCharToMultiByte(CP_UTF8, 0, src, wlen,
out.data(), u8len, nullptr, nullptr);
}
}
GlobalUnlock(h);
CloseClipboard();
if (out.empty()) return std::nullopt;
return out;
#else
return std::nullopt;
#endif
}