Skip to content

Commit f59a6e8

Browse files
committed
feat(clipboard): out-of-band blob endpoints for large payloads
Adds POST /api/v1/clipboard/blob and GET /api/v1/clipboard/blob/<id> to escape the 65500-byte single-frame limit on the encrypted control stream. Lets clients (and the GUI agent) exchange clipboard items larger than the wire frame can carry: images, files, etc. Architecture (preserved): The C++ service stays a dumb facility — just like clipboard_bridge is a dumb byte forwarder, clipboard_blob_store is a dumb byte+mime store. Neither parses protocol kinds, decodes PNG, or knows what KIND_REF is. All semantic work (when to upload, when to send a REF frame, when to fetch) stays in the Rust GUI agent and the streaming clients. New module clipboard_blob_store: * Per-blob TTL (60 s), no read-extend, retries are safe. * Per-blob size cap 50 MiB; total store cap 200 MiB. * FIFO eviction when the store cap would be exceeded. * UUID-v4 ids generated locally; thread-safe public API. * No persistence — process restart drops everything. New endpoints (clipboard_http.cpp): * POST /api/v1/clipboard/blob Body: raw bytes. Required header: X-Clipboard-Mime. 200 -> {id, size, expires_in}. 400 missing_mime / bad_mime / bad_content_length / empty_body. 413 payload_too_large (vs kMaxBlobBytes). 403 clipboard_sync_disabled. * GET /api/v1/clipboard/blob/<id> Body: raw bytes. Content-Type echoes the stored mime. 404 not_found (missing or TTL-expired). Read does not consume — clients can retry on transient errors. Auth: delegated through the existing register_routes auth_fn so the new endpoints inherit confighttp's basic-auth gating exactly like the existing C:/Program Files/Git/clipboard/{capability,item,events}. Build: ninja sunshine clean on UCRT64 (sunshine.exe 67 MiB). Out of scope (separate PRs): * Rust GUI agent — detect large outbound items, upload, build KIND_REF. * Moonlight clients (Qt/Android) — detect inbound KIND_REF, fetch blob. * moonlight-common-c — publish LI_CLIPBOARD_KIND_REF=3 constant.
1 parent 51cd3fc commit f59a6e8

5 files changed

Lines changed: 407 additions & 0 deletions

File tree

cmake/compile_definitions/common.cmake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ set(SUNSHINE_TARGET_FILES
110110
"${CMAKE_SOURCE_DIR}/src/stream.h"
111111
"${CMAKE_SOURCE_DIR}/src/clipboard_bridge.cpp"
112112
"${CMAKE_SOURCE_DIR}/src/clipboard_bridge.h"
113+
"${CMAKE_SOURCE_DIR}/src/clipboard_blob_store.cpp"
114+
"${CMAKE_SOURCE_DIR}/src/clipboard_blob_store.h"
113115
"${CMAKE_SOURCE_DIR}/src/clipboard_http.cpp"
114116
"${CMAKE_SOURCE_DIR}/src/clipboard_http.h"
115117
"${CMAKE_SOURCE_DIR}/src/video.cpp"

src/clipboard_blob_store.cpp

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* @file src/clipboard_blob_store.cpp
3+
* @brief See clipboard_blob_store.h.
4+
*/
5+
#include "clipboard_blob_store.h"
6+
7+
#include <chrono>
8+
#include <deque>
9+
#include <mutex>
10+
#include <random>
11+
#include <sstream>
12+
#include <unordered_map>
13+
#include <utility>
14+
15+
namespace clipboard_blob_store {
16+
namespace {
17+
using clock_t = std::chrono::steady_clock;
18+
19+
struct entry_t {
20+
payload_t bytes;
21+
std::string mime;
22+
clock_t::time_point expires_at;
23+
};
24+
25+
std::mutex g_mu;
26+
// Map keyed by blob_id; insertion order tracked separately for FIFO eviction.
27+
std::unordered_map<blob_id, entry_t> g_entries;
28+
std::deque<blob_id> g_fifo;
29+
std::size_t g_total_bytes = 0;
30+
31+
/// Generate a UUID-v4-shaped 36-char hex id without pulling in boost::uuid
32+
/// here. Cryptographic strength is not required — these ids are scoped to
33+
/// a live HTTPS-authenticated session and expire in 60 s.
34+
std::string
35+
make_id() {
36+
thread_local std::mt19937_64 rng { std::random_device {}() };
37+
std::uniform_int_distribution<std::uint64_t> dist;
38+
39+
std::uint64_t hi = dist(rng);
40+
std::uint64_t lo = dist(rng);
41+
42+
// Force version=4 nibble and variant bits per RFC 4122.
43+
hi = (hi & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL;
44+
lo = (lo & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL;
45+
46+
static constexpr char kHex[] = "0123456789abcdef";
47+
std::string s(36, '-');
48+
auto write_byte = [&](std::size_t pos, std::uint8_t b) {
49+
s[pos] = kHex[b >> 4];
50+
s[pos + 1] = kHex[b & 0xF];
51+
};
52+
53+
// 8-4-4-4-12 layout. Bytes 0..7 = hi, bytes 8..15 = lo.
54+
auto byte_at = [&](int i) -> std::uint8_t {
55+
std::uint64_t v = (i < 8) ? hi : lo;
56+
int shift = (7 - (i & 7)) * 8;
57+
return static_cast<std::uint8_t>((v >> shift) & 0xFF);
58+
};
59+
60+
static constexpr int kSlots[] = { 0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34 };
61+
for (int i = 0; i < 16; ++i) {
62+
write_byte(static_cast<std::size_t>(kSlots[i]), byte_at(i));
63+
}
64+
return s;
65+
}
66+
67+
/// Caller must hold g_mu. Drops everything past TTL.
68+
void
69+
sweep_locked(clock_t::time_point now) {
70+
// FIFO is roughly in insertion order, but TTLs are uniform so it's
71+
// also roughly expiry order. Walk from front and stop at first live one.
72+
while (!g_fifo.empty()) {
73+
auto it = g_entries.find(g_fifo.front());
74+
if (it == g_entries.end()) {
75+
g_fifo.pop_front();
76+
continue;
77+
}
78+
if (it->second.expires_at > now) {
79+
break;
80+
}
81+
g_total_bytes -= it->second.bytes.size();
82+
g_entries.erase(it);
83+
g_fifo.pop_front();
84+
}
85+
}
86+
87+
/// Caller must hold g_mu. Force-evict oldest entries until total bytes
88+
/// + `incoming` fits under `kMaxStoreBytes`.
89+
void
90+
evict_for_locked(std::size_t incoming) {
91+
while (!g_fifo.empty() && g_total_bytes + incoming > kMaxStoreBytes) {
92+
auto it = g_entries.find(g_fifo.front());
93+
g_fifo.pop_front();
94+
if (it != g_entries.end()) {
95+
g_total_bytes -= it->second.bytes.size();
96+
g_entries.erase(it);
97+
}
98+
}
99+
}
100+
} // namespace
101+
102+
put_result_t
103+
put(payload_t bytes, std::string mime) {
104+
if (bytes.size() > kMaxBlobBytes) {
105+
return { {}, false, "too_large" };
106+
}
107+
if (bytes.size() > kMaxStoreBytes) {
108+
// Even after a full FIFO purge it wouldn't fit.
109+
return { {}, false, "too_large" };
110+
}
111+
112+
auto now = clock_t::now();
113+
const std::size_t incoming = bytes.size();
114+
115+
std::lock_guard<std::mutex> lk(g_mu);
116+
sweep_locked(now);
117+
evict_for_locked(incoming);
118+
119+
blob_id id = make_id();
120+
// Defensive: ensure no collision (vanishingly unlikely).
121+
while (g_entries.find(id) != g_entries.end()) {
122+
id = make_id();
123+
}
124+
125+
entry_t e;
126+
e.bytes = std::move(bytes);
127+
e.mime = std::move(mime);
128+
e.expires_at = now + std::chrono::seconds(kBlobTtlSeconds);
129+
130+
g_total_bytes += incoming;
131+
g_fifo.push_back(id);
132+
g_entries.emplace(id, std::move(e));
133+
134+
return { id, true, {} };
135+
}
136+
137+
get_result_t
138+
get(const blob_id &id, bool consume) {
139+
auto now = clock_t::now();
140+
141+
std::lock_guard<std::mutex> lk(g_mu);
142+
sweep_locked(now);
143+
144+
auto it = g_entries.find(id);
145+
if (it == g_entries.end()) {
146+
return { false, {}, {} };
147+
}
148+
149+
if (consume) {
150+
get_result_t r { true, std::move(it->second.bytes), std::move(it->second.mime) };
151+
g_total_bytes -= r.bytes.size();
152+
g_entries.erase(it);
153+
// Lazy: leave the dead id in g_fifo; sweep_locked drops it on next pass.
154+
return r;
155+
}
156+
157+
// Copy out without mutating storage so retries work.
158+
return { true, it->second.bytes, it->second.mime };
159+
}
160+
161+
void
162+
sweep_expired() {
163+
auto now = clock_t::now();
164+
std::lock_guard<std::mutex> lk(g_mu);
165+
sweep_locked(now);
166+
}
167+
168+
stats_t
169+
stats() {
170+
std::lock_guard<std::mutex> lk(g_mu);
171+
return { g_entries.size(), g_total_bytes };
172+
}
173+
} // namespace clipboard_blob_store

src/clipboard_blob_store.h

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @file src/clipboard_blob_store.h
3+
* @brief In-memory TTL-bounded blob storage for out-of-band clipboard payloads
4+
* (large images, files) that exceed the encrypted control-stream's
5+
* single-frame limit (`clipboard_bridge::kMaxPayloadBytes`).
6+
*
7+
* Design:
8+
* * Producer (GUI agent or HTTP upload from a client) puts opaque bytes +
9+
* a MIME hint and gets back a freshly-minted UUID.
10+
* * Consumers (other clients via HTTP, or the GUI agent dispatching an
11+
* inbound large object) GET by id. Reads do NOT remove by default; the
12+
* TTL sweeper reclaims memory eventually so a client may retry on
13+
* transient network failures.
14+
* * No cross-process persistence — restarting Sunshine drops everything.
15+
* Acceptable: clipboard items are short-lived by nature, and the wire
16+
* KIND_REF that points here is also delivered over a live session.
17+
*
18+
* Eviction policy:
19+
* * Per-blob TTL (see `kBlobTtlSeconds`).
20+
* * Hard cap on total resident bytes (`kMaxStoreBytes`); when a put would
21+
* exceed it, oldest blobs are evicted FIFO until it fits, even if their
22+
* TTL hasn't expired.
23+
*
24+
* Threading: all public functions are thread-safe.
25+
*/
26+
#pragma once
27+
28+
#include <cstddef>
29+
#include <cstdint>
30+
#include <string>
31+
#include <vector>
32+
33+
#include "clipboard_bridge.h" // for payload_t
34+
35+
namespace clipboard_blob_store {
36+
using payload_t = clipboard_bridge::payload_t;
37+
using blob_id = std::string; ///< UUID v4 (canonical 36-char hex form).
38+
39+
/// Per-blob hard size cap. Bigger uploads are rejected outright.
40+
constexpr std::size_t kMaxBlobBytes = 50ULL * 1024 * 1024; // 50 MiB
41+
42+
/// Total resident bytes cap across all blobs. FIFO eviction kicks in when
43+
/// a put would exceed this, regardless of TTL.
44+
constexpr std::size_t kMaxStoreBytes = 200ULL * 1024 * 1024; // 200 MiB
45+
46+
/// Per-blob TTL after the most recent put. Reads do NOT extend the TTL.
47+
constexpr int kBlobTtlSeconds = 60;
48+
49+
struct put_result_t {
50+
blob_id id; ///< Empty iff ok == false.
51+
bool ok;
52+
/// Reason for failure when !ok: "too_large" or "internal".
53+
std::string err;
54+
};
55+
56+
/// Store bytes + MIME and return a freshly-minted id. The caller's vector
57+
/// is consumed. Triggers an opportunistic sweep + FIFO eviction.
58+
put_result_t put(payload_t bytes, std::string mime);
59+
60+
struct get_result_t {
61+
bool found;
62+
payload_t bytes;
63+
std::string mime;
64+
};
65+
66+
/// Look up by id. `found == false` for missing or expired entries.
67+
/// When `consume == true` the blob is removed on a successful read; useful
68+
/// for one-shot semantics where the consumer is sole.
69+
get_result_t get(const blob_id &id, bool consume = false);
70+
71+
/// Force expiry of stale entries. Called opportunistically by put(); tests
72+
/// can also invoke it directly.
73+
void sweep_expired();
74+
75+
/// Diagnostics — count and bytes of currently-resident entries.
76+
struct stats_t {
77+
std::size_t entries;
78+
std::size_t bytes;
79+
};
80+
81+
stats_t stats();
82+
} // namespace clipboard_blob_store

0 commit comments

Comments
 (0)