/* 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 #include #include #include #include #include #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #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(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(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(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 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 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(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(text.size()), nullptr, 0); if (wlen < 0) { CloseClipboard(); return false; } HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast(wlen) + 1) * sizeof(wchar_t)); if (h == nullptr) { CloseClipboard(); return false; } wchar_t* dst = static_cast(GlobalLock(h)); if (dst == nullptr) { GlobalFree(h); CloseClipboard(); return false; } MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(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 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(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( std::chrono::duration_cast( 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(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(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(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 }