diff --git a/apps/cli/agent.cpp b/apps/cli/agent.cpp index cbfba9b6..3ffce27d 100644 --- a/apps/cli/agent.cpp +++ b/apps/cli/agent.cpp @@ -79,6 +79,13 @@ struct FrameWait { uint64_t signature, frame; Clock::time_point deadline, changed; }; +struct RemoteJob { + json id; + uint64_t request; + std::string operation; + bool write; + Clock::time_point deadline; +}; struct Capture { json id, request; bool preview = false; @@ -298,8 +305,13 @@ private: std::fill(step.text.begin(), step.text.end(), '\0'); action_.reset(); } + void CancelRemoteJobs(const char *code) { + for (const auto &job : remote_jobs_) FinishRemote(job, {}, code); + remote_jobs_.clear(); + } void Stop(const char *code) { FinishAction(code); + CancelRemoteJobs(code); for (const auto &capture : captures_) Error(capture.id, code); captures_.clear(); @@ -318,6 +330,15 @@ private: } void Tick() { session_.Tick(); + for (auto it = remote_jobs_.begin(); it != remote_jobs_.end();) { + auto result = session_.TakeRemoteResult(it->request); + const char *error = !session_.Connected() ? "SESSION_CLOSED" + : Clock::now() >= it->deadline ? "REMOTE_REQUEST_TIMEOUT" : nullptr; + if (result || error) { + FinishRemote(*it, result ? *result : json{}, error); + it = remote_jobs_.erase(it); + } else ++it; + } // Recycle only uniquely owned frames; an encoder/video worker may still be // reading the old pixels. Save geometry before Take can swap its storage. if (auto f = session_.TakeFrame(frame_ && frame_.use_count() == 1 ? frame_.get() : nullptr)) { @@ -593,6 +614,40 @@ private: throw std::invalid_argument("STALE_OBSERVATION"); return *it; } + void FinishRemote(const RemoteJob &job, const json &reply, const char *error = nullptr) { + session_.ForgetRemoteRequest(job.request); + if (!error && reply.value("ok", json{}) != true) error = "REMOTE_REQUEST_FAILED"; + json result = error ? json{{"status", job.write ? "outcome_unknown" : "failed"}, + {"error", error}, {"side_effect_possible", job.write}} : reply.value("result", json::object()); + if (!job.operation.empty()) { + result["operation_id"] = job.operation; + for (auto &record : records_) if (record.operation == job.operation) record.result = result; + } + Reply(job.id, result); + } + bool PreviousRemote(const json &j, const json &id, const std::string &operation) { + for (const auto &record : records_) { + if (record.operation != operation) continue; + auto arguments = j; + arguments.erase("id"); + if (record.arguments != arguments.dump()) throw std::invalid_argument("OPERATION_ID_CONFLICT"); + Reply(id, record.result); + return true; + } + return false; + } + void TrackRemote(const json &j, const json &id, uint64_t request, bool write, + const std::string &operation = {}) { + if (!request) throw std::invalid_argument("NOT_READY"); + if (!operation.empty()) { + auto arguments = j; + arguments.erase("id"); + while (records_.size() >= 128) records_.pop_front(); + records_.push_back({operation, arguments.dump(), + {{"status", "in_progress"}, {"operation_id", operation}}, Clock::now()}); + } + remote_jobs_.push_back({id, request, operation, write, Clock::now() + Ms(5000)}); + } const Observation &InputObservation(const json &j) { if (!allowed_ || paused_ || !session_.ControlEnabled()) throw std::invalid_argument("CONTROL_DENIED"); @@ -733,6 +788,27 @@ private: } else if (method == "action") { StartAction(j, id); return false; + } else if (method == "clipboard") { + Fields(j, {"id", "method", "operation", "operation_id", "text"}); + if (!allowed_ || paused_ || !session_.ControlEnabled()) + throw std::invalid_argument("CONTROL_DENIED"); + if (session_.Status()["capabilities"].value("clipboard", json{}) != true) + throw std::invalid_argument("CLIPBOARD_UNSUPPORTED"); + const auto operation = String(j, "operation", 8); + if (operation != "get" && operation != "set") throw std::invalid_argument("INVALID_ARGUMENT"); + const bool write = operation == "set"; + const auto operation_id = write ? String(j, "operation_id") : std::string{}; + if (write && PreviousRemote(j, id, operation_id)) return false; + if (remote_jobs_.size() >= 16) throw std::invalid_argument("BUSY"); + std::string text; + if (write) { + if (!j.contains("text") || !j["text"].is_string()) throw std::invalid_argument("INVALID_ARGUMENT"); + text = j["text"].get(); + if (text.size() > 16384 || text.find('\0') != std::string::npos) + throw std::invalid_argument("INVALID_ARGUMENT"); + } + TrackRemote(j, id, session_.Clipboard(write, text), write, operation_id); + return false; } else if (method == "wait_frame") { Fields(j, {"id", "method", "observation_id", "condition", "stable_ms", "timeout_ms"}); const auto &observation = Observe(j); @@ -788,6 +864,7 @@ private: throw std::invalid_argument("CONTROL_DENIED"); if (!enable) FinishAction("CONTROL_DISABLED"); + if (!enable) CancelRemoteJobs("CONTROL_DENIED"); session_.SetControl(enable); } else if (method == "viewer_control") { Fields(j, {"id", "method", "operation"}); @@ -795,6 +872,7 @@ private: if (operation == "pause") { paused_ = true; FinishAction("USER_PAUSED"); + CancelRemoteJobs("USER_PAUSED"); session_.SetControl(false); } else if (operation == "resume") { if (!allowed_) @@ -823,6 +901,12 @@ private: if (!j.contains("request_id") || !j["request_id"].is_number_integer()) throw std::invalid_argument("INVALID_REQUEST"); const auto target = j["request_id"]; + for (auto it = remote_jobs_.begin(); it != remote_jobs_.end();) { + if (it->id == target) { + FinishRemote(*it, {}, "CANCELLED"); + it = remote_jobs_.erase(it); + } else ++it; + } for (auto it = frame_waits_.begin(); it != frame_waits_.end();) { if (it->id == target) { Error(it->id, "CANCELLED"); @@ -896,6 +980,7 @@ private: std::deque observations_; std::deque captures_; std::deque frame_waits_; + std::deque remote_jobs_; std::unique_ptr encoding_; std::unique_ptr terminal_encoding_; std::unique_ptr action_; diff --git a/apps/cli/docs/automation-protocol.md b/apps/cli/docs/automation-protocol.md index 6904a033..74ef1560 100644 --- a/apps/cli/docs/automation-protocol.md +++ b/apps/cli/docs/automation-protocol.md @@ -141,3 +141,7 @@ The result follows the existing action result format. Repeated operation IDs ret Updated desktop hosts answer the versioned `automation` capability handshake. When `capabilities.input_ack` is true, input travels through the host input dispatcher and the action result includes `remote_execution` with `applied`, `failed` or `pending`. A receipt confirms native input processing. The caller checks the target application's resulting state separately. Legacy hosts return `remote_execution` as `unsupported`. Missing receipts terminate the bounded wait with `REMOTE_ACK_TIMEOUT` and an uncertain outcome. `wait_frame` accepts an `observation_id`, `condition` (`changed` or `stable`), `stable_ms` (100–5000, default 500), and `timeout_ms` (0–25000, default 5000). It watches the observation's source region, checks both luma and chroma, and returns `matched`, `wait_timed_out`, `generation`, and `frame_id`. Display changes, disconnection and explicit cancellation terminate pending waits. `crossdesk_wait` exposes this operation through MCP and includes a fresh screenshot when its condition matches. + +## Clipboard + +`clipboard` accepts `operation: get | set`. Writes additionally require `operation_id` and `text` (up to 16384 UTF-8 bytes, including an empty string to clear). Reads return up to 128 KiB of text. The existing chunked `web-clipboard` stream carries values and applied/rejected replies. Additive `read`, `empty`, and `clear` frames are enabled by the remote `clipboard` capability. Clipboard access requires control permission and respects human pause. Cancellation of an issued write reports an uncertain effect. Repeated write operation IDs retrieve the cached result. diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp index c1495b18..cdfdd538 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp @@ -59,6 +59,7 @@ void ClipboardController::Shutdown() { pending_remote_text_.reset(); pending_complete_ = {}; pending_allowed_ = {}; + pending_reads_.clear(); } bool ClipboardController::WebSessionActive( @@ -107,7 +108,7 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size, if (found == web_sessions_.end() || found->second->events.lock() != events) return; session = found->second; } - if (!hello) { + if (!hello && kind != clip::kRead && kind != clip::kClear) { if (!session->ready.load(std::memory_order_acquire) || kind != clip::kText || !request) return; message = session->assembler.Receive(std::string_view(data, size)); @@ -121,6 +122,23 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size, session->ready.store(true, std::memory_order_release); return; } + if ((kind == clip::kRead || kind == clip::kClear) && + (!session->ready.load(std::memory_order_acquire) || !request || + size != clip::kHeaderBytes)) return; + if (kind == clip::kRead) { + const auto send = [this, remote_id, session](const std::string& frame) { + return WebSessionActive(remote_id, session) && + owner_.SendControllerData(remote_id, frame.data(), frame.size(), clip::kStream) == 0; + }; + if (!QueueRead([send, request](std::optional text) { + if (!text) send(clip::Frame(clip::kRejected, request)); + else if (text->empty()) send(clip::Frame(clip::kEmpty, request)); + else if (!clip::SendText(*text, request, 16 * 1024, send)) + send(clip::Frame(clip::kRejected, request)); + })) send(clip::Frame(clip::kRejected, request)); + return; + } + if (kind == clip::kClear) message = clip::Frame(clip::kText, request); if (message.empty()) return; const std::weak_ptr weak = session; auto allowed = [this, remote_id, weak] { @@ -163,7 +181,7 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size, if (!events_enabled_.load(std::memory_order_acquire)) { return; } - if (!data || size == 0) { + if (!data || (size == 0 && !complete)) { return; } if (size > kMaxClipboardTextBytes) { @@ -207,11 +225,37 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size, } } +bool ClipboardController::QueueRead(std::function)> complete) { + { + std::lock_guard lock(pending_mutex_); + if (!events_enabled_.load() || pending_reads_.size() >= 32) return false; + pending_reads_.push_back(std::move(complete)); + } + SDL_Event event{}; + event.type = event_type_; + if (event_type_) SDL_PushEvent(&event); + return true; +} + void ClipboardController::ApplyPendingRemoteText() { if (!events_enabled_.load(std::memory_order_acquire)) { return; } + ApplyPendingWrite(); + std::deque)>> reads; + { + std::lock_guard lock(pending_mutex_); + reads.swap(pending_reads_); + } + if (!reads.empty()) { + char* value = SDL_GetClipboardText(); + const std::optional text = value ? std::optional{value} : std::nullopt; + SDL_free(value); + for (auto& complete : reads) complete(text); + } +} +void ClipboardController::ApplyPendingWrite() { std::optional pending_text; std::string source_id; std::weak_ptr source_session; diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h index 39f93960..e8132920 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,7 @@ public: std::function complete = {}, std::function allowed = {}); void ApplyPendingRemoteText(); + bool QueueRead(std::function)> complete); void HandleLocalUpdate(); void ReceiveWeb(const char* data, size_t size, const std::string& remote_id, PeerEventHandler* source); @@ -49,6 +51,7 @@ public: private: struct WebSession; + void ApplyPendingWrite(); bool WebSessionActive(const std::string& remote_id, const std::shared_ptr& session); int SendToController(const std::string& remote_id, const std::string& text); @@ -59,6 +62,7 @@ private: uint32_t event_type_ = 0; std::atomic events_enabled_{false}; std::mutex pending_mutex_; + std::deque)>> pending_reads_; std::optional pending_remote_text_; std::string pending_remote_id_; std::weak_ptr pending_session_; diff --git a/apps/desktop/src/gui/runtime/automation_runtime.cpp b/apps/desktop/src/gui/runtime/automation_runtime.cpp index 7f2e9f00..2d2bad0a 100644 --- a/apps/desktop/src/gui/runtime/automation_runtime.cpp +++ b/apps/desktop/src/gui/runtime/automation_runtime.cpp @@ -21,7 +21,7 @@ void GuiRuntime::HandleAutomation(const std::string& remote_id, try { const auto method = request.value("method", std::string{}); if (method == "capabilities") { - reply(true, {{"input_ack", true}}); + reply(true, {{"input_ack", true}, {"clipboard", true}}); } else if (method == "input") { RemoteAction action{}; if (!request.contains("arguments") || diff --git a/apps/mcp/README.md b/apps/mcp/README.md index c2123983..fc6da058 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -250,3 +250,7 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts 更新后的被控端通过 `capabilities.input_ack` 声明输入处理回执。动作结果的 `remote_execution` 表示远端原生输入处理状态,应用界面的实际结果由后续观察确认。旧被控端返回 `unsupported`。回执等待有明确期限,超时动作保留结果不确定状态。 `crossdesk_wait` 使用已有截图的 `observation_id`,等待对应区域变化(`changed`)或持续稳定(`stable`)。可设置 `stable_ms` 和最长 25000 毫秒的 `timeout_ms`。满足条件后返回新截图,切换显示器、断开和取消会结束等待。 + +### 远端剪贴板 + +`crossdesk_clipboard_get` 读取远端文本剪贴板,`crossdesk_clipboard_set` 设置文本,空字符串清空。读取上限为 128 KiB,单次工具写入上限为 16 KiB UTF-8。写入需要 `operation_id`,相同请求查询原结果。工具使用已授权控制会话,并遵守人工暂停及解锁等待状态。更新后的被控端通过 `capabilities.clipboard` 声明支持,写入成功由系统剪贴板更新回执确认。 diff --git a/apps/mcp/src/native.ts b/apps/mcp/src/native.ts index 17a58d94..4386db12 100644 --- a/apps/mcp/src/native.ts +++ b/apps/mcp/src/native.ts @@ -173,6 +173,8 @@ export class NativeClient { if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144) return Promise.reject(new Failure("BUSY", false, true)); const id = ++this.sequence; + const sideEffect = method === "action" || method === "unlock" || + (method === "clipboard" && args.operation === "set"); const line = JSON.stringify({ ...args, id, method }) + "\n"; if (Buffer.byteLength(line) > 65536) return Promise.reject(new Failure("MESSAGE_TOO_LARGE")); @@ -190,7 +192,7 @@ export class NativeClient { request_id: id, }) + "\n", ); - reject(new Failure(code, method === "action" || method === "unlock")); + reject(new Failure(code, sideEffect)); }; const abort = () => cancel("CANCELLED"); const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout); @@ -198,7 +200,7 @@ export class NativeClient { this.pending.set(id, { resolve, reject, - action: method === "action" || method === "unlock", + action: sideEffect, clean: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 35e36244..ed7d86cf 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -312,6 +312,14 @@ export function createServer( "Enable or disable control within the user-authorized scope. Cannot override human pause or upgrade an observe-only connection.", async (p) => output(await sessions.control(p.session_id, p.enabled)), ); + add("crossdesk_clipboard_get", z.strictObject({ session_id: id }), + "Read the remote text clipboard within the authorized control session.", + async (p, ctx) => output(await sessions.clipboard({ ...p, operation: "get" }, ctx.mcpReq.signal)), + { readOnly: true }); + add("crossdesk_clipboard_set", z.strictObject({ session_id: id, operation_id: id, + text: z.string().max(16384).refine((text) => Buffer.byteLength(text) <= 16384 && !text.includes("\0")) }), + "Set or clear the remote text clipboard. Reuse the same operation_id to query the original result. Returns applied after the remote clipboard update.", + async (p, ctx) => output(await sessions.clipboard({ ...p, operation: "set" }, ctx.mcpReq.signal))); add( "crossdesk_select_display", z.strictObject({ session_id: id, display_index: z.int().min(0).max(63) }), diff --git a/apps/mcp/src/sessions.ts b/apps/mcp/src/sessions.ts index f89e705d..b29ead83 100644 --- a/apps/mcp/src/sessions.ts +++ b/apps/mcp/src/sessions.ts @@ -466,6 +466,13 @@ export class Sessions { if (result.matched) result.observation = await this.screenshot({ session_id }, signal); return result; } + async clipboard(p: Data, signal?: AbortSignal): Promise { + const { session_id, ...args } = p; + const s = this.session(session_id); + this.checkUnlock(s); + if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING"); + return s.native.request("clipboard", args, 7000, signal); + } async control(id: string, enabled: boolean) { if (!enabled) this.cancelUnlock(this.session(id)); return this.session(id).native.request("control", { enabled }); diff --git a/libs/controller/include/controller_session.h b/libs/controller/include/controller_session.h index 8aaa0ee2..a0bb8286 100644 --- a/libs/controller/include/controller_session.h +++ b/libs/controller/include/controller_session.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,8 @@ public: bool Pointer(float x, float y, int flag, int wheel = 0); uint64_t RequestRemote(const std::string &method, nlohmann::json arguments = {}); std::optional TakeRemoteResult(uint64_t request); + void ForgetRemoteRequest(uint64_t request) { remote_results_.erase(request); } + uint64_t Clipboard(bool write, const std::string &text = {}); void BeginInput(); std::string InputResult(); bool InputAcknowledged() const { return remote_capabilities_.value("input_ack", nlohmann::json{}) == true; } @@ -121,6 +124,8 @@ private: std::vector input_requests_; std::string input_result_ = "applied"; bool input_ack_active_ = false; + bool clipboard_ready_ = false; + web_clipboard::Assembler clipboard_assembler_; uint64_t generation_ = 0; uint32_t preview_request_ = 0, preview_ack_ = 0; bool control_ = false, preview_supported_ = false, resolution_ = false; diff --git a/libs/controller/src/controller_requests.cpp b/libs/controller/src/controller_requests.cpp index 3a801ad5..9204789f 100644 --- a/libs/controller/src/controller_requests.cpp +++ b/libs/controller/src/controller_requests.cpp @@ -54,3 +54,19 @@ std::string ControllerSession::InputResult() { return input_requests_.empty() ? input_result_ : "pending"; } } // namespace crossdesk + +namespace crossdesk { +uint64_t ControllerSession::Clipboard(bool write, const std::string &text) { + namespace clip = web_clipboard; + if (!Connected() || !control_ || !clipboard_ready_ || remote_results_.size() >= 512 || + remote_sequence_ >= UINT32_MAX || (write && !text.empty() && !clip::ValidText(text))) + return 0; + const auto id = uint32_t(++remote_sequence_); + remote_results_[id] = nullptr; + const auto send = [this](const std::string &frame) { return Send(clip::kStream, frame); }; + const bool sent = write && !text.empty() ? clip::SendText(text, id, 16 * 1024, send) + : send(clip::Frame(write ? clip::kClear : clip::kRead, id)); + if (!sent) { remote_results_.erase(id); return 0; } + return id; +} +} // namespace crossdesk diff --git a/libs/controller/src/controller_session.cpp b/libs/controller/src/controller_session.cpp index 4a5e4ff1..c2df2ef9 100644 --- a/libs/controller/src/controller_session.cpp +++ b/libs/controller/src/controller_session.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,12 @@ void ControllerSession::Data(const char *data, size_t n, const char *, size_t, const char *stream, size_t sn, void *p) { auto &session = *static_cast(p); try { + if (data && n && n <= web_clipboard::kHeaderBytes + web_clipboard::kChunkBytes && + stream && sn == std::strlen(web_clipboard::kStream) && + std::string_view(stream, sn) == web_clipboard::kStream) { + session.Enqueue({3, 0, std::string(data, n)}); + return; + } if (data && n && n <= 65536 && stream && sn < 64 && (std::string(stream, sn) == kControlStream || std::string(stream, sn) == kWebControlStream)) @@ -122,6 +129,8 @@ void ControllerSession::Connect(SessionOptions options, std::string device, remote_results_.clear(); input_requests_.clear(); capability_request_ = 0; + clipboard_ready_ = false; + clipboard_assembler_ = {}; displays_.clear(); host_platform_ = HostPlatform::Unknown; preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ = @@ -155,6 +164,7 @@ void ControllerSession::Connect(SessionOptions options, std::string device, AddDataStream(peer_, kDataStream, false); for (auto stream : {kMouseStream, kKeyboardStream, kControlStream}) AddDataStream(peer_, stream, true); + AddDataStream(peer_, web_clipboard::kStream, true); if (Init(peer_) != 0) Fail("media_initialization_failed"); } @@ -240,6 +250,24 @@ void ControllerSession::Tick() { Fail(Connected() ? "connection_lost" : "connection_failed"); else if (e.code == Closed || e.code == Disconnected) Fail("remote_disconnected"); + } else if (e.kind == 3 && Connected()) { + namespace clip = web_clipboard; + if (e.data.size() < clip::kHeaderBytes) continue; + const auto kind = clip::Read32(e.data.data()); + const auto id = clip::Read32(e.data.data() + 4); + if (kind == clip::kReady && !id) { clipboard_ready_ = true; continue; } + const auto found = remote_results_.find(id); + if (!id || found == remote_results_.end()) continue; + if (kind == clip::kApplied || kind == clip::kRejected) { + found->second = {{"ok", kind == clip::kApplied}, + {"result", {{"applied", kind == clip::kApplied}}}, {"error", "CLIPBOARD_REJECTED"}}; + } else if (kind == clip::kEmpty) { + found->second = {{"ok", true}, {"result", {{"text", ""}}}}; + } else if (kind == clip::kText) { + const auto text = clipboard_assembler_.Receive(e.data); + if (!text.empty()) found->second = {{"ok", true}, + {"result", {{"text", text.substr(clip::kHeaderBytes)}}}}; + } } else if (e.kind == 2 && Connected()) { auto j = ParseBoundedJson(e.data); if (!j.is_object()) @@ -254,6 +282,9 @@ void ControllerSession::Tick() { if (id == capability_request_) { if (j.value("ok", json{}) == true && j.value("result", json{}).is_object()) remote_capabilities_ = j["result"]; + if (remote_capabilities_.value("clipboard", json{}) == true) + Send(web_clipboard::kStream, web_clipboard::Frame( + web_clipboard::kHello, 0, web_clipboard::kVersion)); remote_results_.erase(found); } } @@ -475,6 +506,7 @@ json ControllerSession::Status() const { {"display", options_.display}, {"text_input", text_supported_}, {"capabilities", remote_capabilities_}, + {"clipboard_ready", clipboard_ready_}, {"preview_supported", preview_supported_}, {"preview_request", preview_request_}, {"preview_ack", preview_ack_}, diff --git a/libs/wire/include/web_clipboard_protocol.h b/libs/wire/include/web_clipboard_protocol.h index 98b721ae..73e76447 100644 --- a/libs/wire/include/web_clipboard_protocol.h +++ b/libs/wire/include/web_clipboard_protocol.h @@ -23,6 +23,7 @@ inline constexpr size_t kHeaderBytes = 16; inline constexpr size_t kChunkBytes = 16 * 1024; inline constexpr uint32_t kText = 1, kApplied = 2, kRejected = 3; inline constexpr uint32_t kHello = 4, kReady = 5; +inline constexpr uint32_t kRead = 6, kEmpty = 7, kClear = 8; inline uint32_t Read32(const char* data) { const auto* p = reinterpret_cast(data);