mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 04:52:39 +08:00
[feat] add CrossDesk MCP with remote desktop panels
This commit is contained in:
+3
-1
@@ -80,6 +80,8 @@ crossdesk-cli 123456789 --display 1
|
||||
|
||||
## 更多文档与功能范围
|
||||
|
||||
- [MCP 服务与实时面板](../mcp/README.md):Agent 截图和结构化远程操作,不要求控制端安装桌面客户端。
|
||||
- [原生自动化协议](docs/automation-protocol.md):独立 `agent --stdio` 模式。
|
||||
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
|
||||
- [私有桥接协议](docs/bridge-protocol.md)
|
||||
- [实际验证记录](docs/validation.md)
|
||||
@@ -87,4 +89,4 @@ crossdesk-cli 123456789 --display 1
|
||||
|
||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
||||
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。CLI 不创建被控端虚拟桌面,不提供音频播放、文件传输或剪贴板同步;MCP 服务由独立的 `apps/mcp` 包提供。
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
#include "agent.h"
|
||||
#include "terminal_io.h"
|
||||
#include "video_server.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <frame_image.h>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
namespace {
|
||||
using nlohmann::json;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Ms = std::chrono::milliseconds;
|
||||
constexpr size_t kMaxResponse = 4 * 1024 * 1024;
|
||||
void Fields(const json &j, std::initializer_list<const char *> names) {
|
||||
if (!j.is_object())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
if (std::none_of(names.begin(), names.end(),
|
||||
[&](const char *s) { return it.key() == s; }))
|
||||
throw std::invalid_argument("UNKNOWN_FIELD");
|
||||
}
|
||||
int Number(const json &j, const char *key, int low, int high, int fallback) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_number_integer())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
const auto n = j[key].get<int64_t>();
|
||||
if (n < low || n > high)
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
return int(n);
|
||||
}
|
||||
uint64_t Sequence(const json &j, const char *key, uint64_t fallback = 0) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_number_unsigned())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
return j[key].get<uint64_t>();
|
||||
}
|
||||
bool Flag(const json &j, const char *key, bool fallback = false) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_boolean())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
return j[key].get<bool>();
|
||||
}
|
||||
std::string String(const json &j, const char *key, size_t limit = 128) {
|
||||
if (!j.contains(key) || !j[key].is_string())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
auto s = j[key].get<std::string>();
|
||||
if (s.empty() || s.size() > limit || s.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
return s;
|
||||
}
|
||||
int64_t Age(Clock::time_point queued) {
|
||||
return std::chrono::duration_cast<Ms>(Clock::now() - queued).count();
|
||||
}
|
||||
class Responses {
|
||||
public:
|
||||
explicit Responses(int fd) : fd_(fd), mode_(fd, true) {}
|
||||
void Put(json j) {
|
||||
auto line = j.dump(-1, ' ', false, json::error_handler_t::replace) + '\n';
|
||||
if (line.size() > kMaxResponse || queue_.size() >= 64 ||
|
||||
bytes_ + line.size() > 8 * 1024 * 1024)
|
||||
throw std::runtime_error("OUTPUT_BACKPRESSURE");
|
||||
if (queue_.empty())
|
||||
progressed_ = Clock::now();
|
||||
bytes_ += line.size();
|
||||
queue_.push_back(std::move(line));
|
||||
}
|
||||
bool Empty() const { return queue_.empty(); }
|
||||
void Pump() {
|
||||
if (Empty())
|
||||
return;
|
||||
const auto &s = queue_.front();
|
||||
auto n = Write(fd_, s.data() + offset_,
|
||||
std::min(size_t(16384), s.size() - offset_));
|
||||
if (n > 0) {
|
||||
offset_ += n;
|
||||
bytes_ -= n;
|
||||
progressed_ = Clock::now();
|
||||
if (offset_ == s.size()) {
|
||||
queue_.pop_front();
|
||||
offset_ = 0;
|
||||
}
|
||||
} else if (n < 0 && errno != EAGAIN && errno != EINTR)
|
||||
throw std::runtime_error("OUTPUT_CLOSED");
|
||||
if (Clock::now() - progressed_ > std::chrono::seconds(10))
|
||||
throw std::runtime_error("OUTPUT_TIMEOUT");
|
||||
}
|
||||
|
||||
private:
|
||||
int fd_;
|
||||
NonBlocking mode_;
|
||||
std::deque<std::string> queue_;
|
||||
size_t offset_ = 0, bytes_ = 0;
|
||||
Clock::time_point progressed_ = Clock::now();
|
||||
};
|
||||
struct Observation {
|
||||
std::string id;
|
||||
uint64_t generation, epoch;
|
||||
int width, height, source_width, source_height;
|
||||
ImageRegion region;
|
||||
Clock::time_point created;
|
||||
};
|
||||
struct Capture {
|
||||
json id, request;
|
||||
bool preview = false;
|
||||
Clock::time_point deadline;
|
||||
};
|
||||
struct Encoding {
|
||||
Capture capture;
|
||||
std::shared_ptr<const DecodedFrame> frame;
|
||||
uint64_t epoch;
|
||||
bool cancelled = false;
|
||||
std::future<FrameImage> result;
|
||||
};
|
||||
struct Step {
|
||||
int delay = 0, kind = 0, key = 0, flag = 0, wheel = 0;
|
||||
float x = 0, y = 0;
|
||||
bool down = false;
|
||||
std::string text;
|
||||
};
|
||||
struct RunningAction {
|
||||
json id;
|
||||
std::string operation;
|
||||
uint64_t generation = 0, epoch = 0;
|
||||
std::vector<Step> steps;
|
||||
size_t next = 0;
|
||||
bool submitted = false;
|
||||
Clock::time_point started;
|
||||
};
|
||||
struct Record {
|
||||
std::string operation, arguments;
|
||||
json result;
|
||||
Clock::time_point created;
|
||||
};
|
||||
int KeyCode(const std::string &key) {
|
||||
if (key.size() == 1 &&
|
||||
((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= '0' && key[0] <= '9')))
|
||||
return key[0];
|
||||
static const std::map<std::string, int> keys = {
|
||||
{"BACKSPACE", 8}, {"TAB", 9}, {"ENTER", 13}, {"SHIFT", 16},
|
||||
{"CTRL", 17}, {"ALT", 18}, {"PAUSE", 19}, {"CAPS_LOCK", 20},
|
||||
{"ESC", 27}, {"SPACE", 32}, {"PAGE_UP", 33}, {"PAGE_DOWN", 34},
|
||||
{"END", 35}, {"HOME", 36}, {"LEFT", 37}, {"UP", 38},
|
||||
{"RIGHT", 39}, {"DOWN", 40}, {"INSERT", 45}, {"DELETE", 46},
|
||||
{"META", 91}, {"F1", 112}, {"F2", 113}, {"F3", 114},
|
||||
{"F4", 115}, {"F5", 116}, {"F6", 117}, {"F7", 118},
|
||||
{"F8", 119}, {"F9", 120}, {"F10", 121}, {"F11", 122},
|
||||
{"F12", 123}};
|
||||
auto it = keys.find(key);
|
||||
if (it == keys.end())
|
||||
throw std::invalid_argument("INVALID_KEY");
|
||||
return it->second;
|
||||
}
|
||||
class Automation {
|
||||
public:
|
||||
Automation(int output, SessionOptions options, int password, bool video)
|
||||
: output_(output), options_(std::move(options)), password_fd_(password),
|
||||
video_(video ? std::make_unique<VideoServer>() : nullptr) {}
|
||||
~Automation() { session_.Disconnect(); }
|
||||
int Run() {
|
||||
NonBlocking input(0);
|
||||
std::string buffer;
|
||||
bool eof = false, quit = false;
|
||||
auto until = Clock::time_point::max();
|
||||
while (!interrupted) {
|
||||
Tick();
|
||||
output_.Pump();
|
||||
PumpVideo();
|
||||
if (!quit) {
|
||||
char bytes[8192];
|
||||
const auto n = Read(0, bytes, sizeof(bytes));
|
||||
if (n > 0)
|
||||
buffer.append(bytes, size_t(n));
|
||||
else if (!n)
|
||||
eof = true;
|
||||
else if (errno != EAGAIN && errno != EINTR)
|
||||
eof = true;
|
||||
if (buffer.size() > 65536) {
|
||||
Error(nullptr, "MESSAGE_TOO_LARGE");
|
||||
quit = true;
|
||||
}
|
||||
for (int count = 0; count < 16 && !quit; ++count) {
|
||||
const auto end = buffer.find('\n');
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
auto request = ParseBoundedJson(buffer.substr(0, end));
|
||||
buffer.erase(0, end + 1);
|
||||
quit = Handle(request);
|
||||
}
|
||||
if (eof && buffer.find('\n') == std::string::npos) {
|
||||
if (!buffer.empty())
|
||||
Error(nullptr, "INCOMPLETE_MESSAGE");
|
||||
quit = true;
|
||||
}
|
||||
}
|
||||
if (quit && until == Clock::time_point::max()) {
|
||||
Stop("CANCELLED");
|
||||
session_.Disconnect();
|
||||
PumpVideo();
|
||||
until = Clock::now() + std::chrono::seconds(2);
|
||||
}
|
||||
if (quit && (output_.Empty() || Clock::now() >= until))
|
||||
break;
|
||||
WaitIo(quit ? -1 : 0, output_.Empty() ? -1 : output_fd(), 5);
|
||||
}
|
||||
return interrupted ? 130 : 0;
|
||||
}
|
||||
void SetOutputFd(int fd) { output_fd_ = fd; }
|
||||
|
||||
private:
|
||||
int output_fd() const { return output_fd_; }
|
||||
json Status() const {
|
||||
auto result = session_.Status();
|
||||
result["video"] = {{"decode_path", decode_path_}, {"decoder", decoder_}};
|
||||
result["video_output"]["enabled"] = video_ && video_->Enabled();
|
||||
result["video_output"]["protocol"] =
|
||||
decode_path_ == "minirtc" ? "nv12/1" : "h264-annexb/1";
|
||||
if (video_) result["video_output"].update(video_->Status());
|
||||
if (decode_path_ == "minirtc") {
|
||||
result["video_output"]["received"] = frame_ ? frame_->frame_id : 0;
|
||||
result["video_output"]["waiting_keyframe"] = false;
|
||||
result["video_output"].erase("has_sps");
|
||||
result["video_output"].erase("has_pps");
|
||||
}
|
||||
result["transport_state"] = result["state"];
|
||||
result["geometry_epoch"] = epoch_;
|
||||
result["human_paused"] = paused_;
|
||||
result["control_allowed"] = allowed_;
|
||||
result["frame_age_ms"] = frame_ ? json(Age(frame_->queued)) : json(nullptr);
|
||||
result["source_size"] =
|
||||
frame_ ? json{{"width", frame_->width}, {"height", frame_->height}}
|
||||
: json(nullptr);
|
||||
if (session_.Connected())
|
||||
result["state"] = frame_ && frame_->generation == session_.Generation()
|
||||
? "ready"
|
||||
: "waiting_frame";
|
||||
return result;
|
||||
}
|
||||
void PumpVideo() {
|
||||
if (!video_) return;
|
||||
const auto generation = session_.Generation();
|
||||
const bool connected = session_.Connected();
|
||||
const bool decoded = decode_path_ == "minirtc";
|
||||
if (generation != video_generation_ || connected != video_connected_) {
|
||||
video_->Bind(generation, decoded, connected);
|
||||
video_generation_ = generation;
|
||||
video_connected_ = connected;
|
||||
last_video_frame_ = 0;
|
||||
}
|
||||
const bool enabled = video_->Enabled();
|
||||
const bool key = video_->TakeKeyframeRequest();
|
||||
if (enabled != video_enabled_ || key) {
|
||||
session_.SetVideoOutput(enabled && !decoded, key && !decoded);
|
||||
video_enabled_ = enabled;
|
||||
last_video_frame_ = 0;
|
||||
}
|
||||
if (!enabled) return;
|
||||
if (decoded) {
|
||||
if (frame_ && frame_->generation == generation && Age(frame_->queued) <= 2000 &&
|
||||
frame_->frame_id != last_video_frame_) {
|
||||
video_->Submit(frame_);
|
||||
last_video_frame_ = frame_->frame_id;
|
||||
}
|
||||
} else {
|
||||
for (int count = 0; count < 8; ++count) {
|
||||
auto frame = session_.TakeEncodedVideo();
|
||||
if (!frame) break;
|
||||
video_->Submit(std::move(*frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
void Reply(const json &id, json value) {
|
||||
output_.Put({{"id", id}, {"ok", true}, {"result", std::move(value)}});
|
||||
}
|
||||
void Error(const json &id, const std::string &code,
|
||||
bool side_effect = false) {
|
||||
output_.Put({{"id", id},
|
||||
{"ok", false},
|
||||
{"error",
|
||||
{{"code", code},
|
||||
{"retryable", !side_effect && (code == "FRAME_TIMEOUT" ||
|
||||
code == "BUSY")},
|
||||
{"side_effect_possible", side_effect}}}});
|
||||
}
|
||||
void Release() {
|
||||
const bool enabled = session_.Status()["control"];
|
||||
session_.SetControl(false);
|
||||
if (enabled && allowed_ && !paused_ && session_.Connected())
|
||||
session_.SetControl(true);
|
||||
}
|
||||
void FinishAction(const char *error = nullptr) {
|
||||
if (!action_)
|
||||
return;
|
||||
Release();
|
||||
json result = {
|
||||
{"status", error ? (action_->submitted ? "outcome_unknown"
|
||||
: "failed_before_submit")
|
||||
: "submitted"},
|
||||
{"operation_id", action_->operation},
|
||||
{"generation", action_->generation},
|
||||
{"after_frame_id", frame_ ? frame_->frame_id : 0},
|
||||
{"side_effect_possible", action_->submitted},
|
||||
{"retryable", false}};
|
||||
if (error)
|
||||
result["error"] = error;
|
||||
for (auto &record : records_)
|
||||
if (record.operation == action_->operation) {
|
||||
record.result = result;
|
||||
break;
|
||||
}
|
||||
Reply(action_->id, result);
|
||||
action_.reset();
|
||||
}
|
||||
void Stop(const char *code) {
|
||||
FinishAction(code);
|
||||
for (const auto &capture : captures_)
|
||||
Error(capture.id, code);
|
||||
captures_.clear();
|
||||
if (encoding_ && !encoding_->cancelled) {
|
||||
Error(encoding_->capture.id, code);
|
||||
encoding_->cancelled = true;
|
||||
}
|
||||
observations_.clear();
|
||||
preview_ = json();
|
||||
}
|
||||
void Tick() {
|
||||
session_.Tick();
|
||||
if (auto f = session_.TakeFrame()) {
|
||||
if (!frame_ || f->generation != frame_->generation ||
|
||||
f->width != frame_->width || f->height != frame_->height) {
|
||||
++epoch_;
|
||||
observations_.clear();
|
||||
preview_ = json();
|
||||
}
|
||||
frame_ = std::make_shared<DecodedFrame>(std::move(*f));
|
||||
}
|
||||
if (frame_ && frame_->generation != session_.Generation()) {
|
||||
frame_.reset();
|
||||
++epoch_;
|
||||
observations_.clear();
|
||||
preview_ = json();
|
||||
}
|
||||
if (action_) {
|
||||
if (!session_.Connected() || !frame_ || paused_ || !allowed_ ||
|
||||
action_->generation != session_.Generation() ||
|
||||
action_->epoch != epoch_)
|
||||
FinishAction("SESSION_CHANGED");
|
||||
while (action_ && action_->next < action_->steps.size() &&
|
||||
Age(action_->started) >= action_->steps[action_->next].delay) {
|
||||
const auto &step = action_->steps[action_->next++];
|
||||
action_->submitted =
|
||||
true; // A failed send may still have reached the peer.
|
||||
const bool sent =
|
||||
step.kind == 0
|
||||
? session_.Pointer(step.x, step.y, step.flag, step.wheel)
|
||||
: step.kind == 1 ? session_.Key(step.key, step.down)
|
||||
: session_.Text(step.text);
|
||||
if (!sent) {
|
||||
FinishAction("ACTION_OUTCOME_UNKNOWN");
|
||||
break;
|
||||
}
|
||||
action_->submitted = true;
|
||||
}
|
||||
if (action_ && action_->next == action_->steps.size())
|
||||
FinishAction();
|
||||
}
|
||||
if (encoding_ &&
|
||||
encoding_->result.wait_for(Ms(0)) == std::future_status::ready) {
|
||||
auto job = std::move(encoding_);
|
||||
try {
|
||||
auto image = job->result.get();
|
||||
if (!job->cancelled) {
|
||||
if (!frame_ || job->epoch != epoch_ ||
|
||||
job->frame->generation != session_.Generation())
|
||||
Error(job->capture.id, "STALE_OBSERVATION");
|
||||
else if (Age(job->frame->queued) > 2000 ||
|
||||
Clock::now() > job->capture.deadline)
|
||||
Error(job->capture.id, "FRAME_TIMEOUT");
|
||||
else {
|
||||
const auto &f = *job->frame;
|
||||
json meta = {
|
||||
{"generation", f.generation},
|
||||
{"geometry_epoch", job->epoch},
|
||||
{"frame_id", f.frame_id},
|
||||
{"display_index", session_.Status()["display"]},
|
||||
{"source_size", {{"width", f.width}, {"height", f.height}}},
|
||||
{"source_rect",
|
||||
{{"x", image.region.x},
|
||||
{"y", image.region.y},
|
||||
{"width", image.region.width},
|
||||
{"height", image.region.height}}},
|
||||
{"image_size",
|
||||
{{"width", image.width}, {"height", image.height}}},
|
||||
{"frame_age_ms", Age(f.queued)}};
|
||||
json result = {{"mime_type", "image/png"},
|
||||
{"data", ImageBase64(image.png)},
|
||||
{"metadata", meta}};
|
||||
if (job->capture.preview) {
|
||||
preview_ = result;
|
||||
preview_time_ = f.queued;
|
||||
preview_edge_ =
|
||||
Number(job->capture.request, "max_edge", 64, 960, 960);
|
||||
} else {
|
||||
const auto id = "obs_" + std::to_string(f.generation) + "_" +
|
||||
std::to_string(++observation_sequence_);
|
||||
result["metadata"]["observation_id"] = id;
|
||||
if (observations_.size() >= 32)
|
||||
observations_.pop_front();
|
||||
observations_.push_back(
|
||||
{id, f.generation, job->epoch, image.width, image.height,
|
||||
int(f.width), int(f.height), image.region, Clock::now()});
|
||||
}
|
||||
Reply(job->capture.id, std::move(result));
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
if (!job->cancelled)
|
||||
Error(job->capture.id, "IMAGE_ENCODING_FAILED");
|
||||
}
|
||||
}
|
||||
for (auto it = captures_.begin(); it != captures_.end();) {
|
||||
if (it->request.contains("generation") &&
|
||||
Sequence(it->request, "generation") != session_.Generation()) {
|
||||
Error(it->id, "STALE_OBSERVATION");
|
||||
it = captures_.erase(it);
|
||||
} else if (Clock::now() > it->deadline || session_.Finished()) {
|
||||
Error(it->id, session_.Finished() ? "NOT_READY" : "FRAME_TIMEOUT");
|
||||
it = captures_.erase(it);
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
if (!encoding_ && !captures_.empty() && frame_ &&
|
||||
Age(frame_->queued) <= 2000) {
|
||||
auto it = std::find_if(
|
||||
captures_.begin(), captures_.end(), [&](const Capture &c) {
|
||||
return !c.request.contains("after_frame_id") ||
|
||||
frame_->frame_id > Sequence(c.request, "after_frame_id");
|
||||
});
|
||||
if (it != captures_.end()) {
|
||||
auto capture = *it;
|
||||
const auto capture_id = capture.id;
|
||||
captures_.erase(it);
|
||||
try {
|
||||
Encode(std::move(capture));
|
||||
} catch (...) {
|
||||
Error(capture_id, "INVALID_REGION");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void Encode(Capture capture) {
|
||||
ImageRegion region;
|
||||
if (capture.request.contains("region")) {
|
||||
const auto &r = capture.request["region"];
|
||||
Fields(r, {"x", "y", "width", "height"});
|
||||
region = {Number(r, "x", 0, int(frame_->width) - 1, -1),
|
||||
Number(r, "y", 0, int(frame_->height) - 1, -1),
|
||||
Number(r, "width", 1, frame_->width, -1),
|
||||
Number(r, "height", 1, frame_->height, -1)};
|
||||
if (region.x < 0 || region.y < 0 || region.width < 1 ||
|
||||
region.height < 1 || region.x > int(frame_->width) - region.width ||
|
||||
region.y > int(frame_->height) - region.height)
|
||||
throw std::invalid_argument("INVALID_REGION");
|
||||
}
|
||||
const int edge =
|
||||
Number(capture.request, "max_edge", 64, capture.preview ? 960 : 2048,
|
||||
capture.preview ? 960 : 1280);
|
||||
auto job = std::make_unique<Encoding>();
|
||||
job->capture = capture;
|
||||
job->frame = frame_;
|
||||
job->epoch = epoch_;
|
||||
job->result = std::async(std::launch::async, [frame = frame_, region, edge,
|
||||
preview = capture.preview] {
|
||||
return EncodeFrameImage(*frame, region, edge,
|
||||
preview ? 256 * 1024 : 2 * 1024 * 1024);
|
||||
});
|
||||
encoding_ = std::move(job);
|
||||
}
|
||||
void CaptureFrame(const json &j, const json &id, bool preview) {
|
||||
Fields(j, {"id", "method", "region", "max_edge", "after_frame_id",
|
||||
"generation", "timeout_ms"});
|
||||
if (!session_.Connected())
|
||||
throw std::invalid_argument("NOT_READY");
|
||||
Number(j, "max_edge", 64, preview ? 960 : 2048, preview ? 960 : 1280);
|
||||
if (preview && j.contains("region"))
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
if (j.contains("after_frame_id") && !j.contains("generation"))
|
||||
throw std::invalid_argument("GENERATION_REQUIRED");
|
||||
Sequence(j, "after_frame_id");
|
||||
if (j.contains("generation") &&
|
||||
Sequence(j, "generation") != session_.Generation())
|
||||
throw std::invalid_argument("STALE_OBSERVATION");
|
||||
if (preview && frame_ && j.contains("after_frame_id") &&
|
||||
Sequence(j, "after_frame_id") >= frame_->frame_id) {
|
||||
Reply(id, {{"unchanged", true},
|
||||
{"generation", frame_->generation},
|
||||
{"frame_id", frame_->frame_id},
|
||||
{"frame_age_ms", Age(frame_->queued)},
|
||||
{"stale", Age(frame_->queued) > 2000}});
|
||||
return;
|
||||
}
|
||||
if (preview && frame_ && !preview_.is_null() &&
|
||||
Age(preview_time_) <= 2000 &&
|
||||
preview_edge_ == Number(j, "max_edge", 64, 960, 960) &&
|
||||
preview_["metadata"]["generation"] == frame_->generation &&
|
||||
preview_["metadata"]["frame_id"] == frame_->frame_id) {
|
||||
auto cached = preview_;
|
||||
cached["metadata"]["frame_age_ms"] = Age(preview_time_);
|
||||
Reply(id, cached);
|
||||
return;
|
||||
}
|
||||
if (captures_.size() >= 16)
|
||||
throw std::invalid_argument("BUSY");
|
||||
Capture c{id, j, preview,
|
||||
Clock::now() + Ms(Number(j, "timeout_ms", 0, 5000, 2000))};
|
||||
if (preview)
|
||||
captures_.push_back(c);
|
||||
else
|
||||
captures_.push_front(c);
|
||||
}
|
||||
const Observation &Observe(const json &j) {
|
||||
auto name = String(j, "observation_id");
|
||||
auto it = std::find_if(observations_.begin(), observations_.end(),
|
||||
[&](const Observation &o) { return o.id == name; });
|
||||
if (it == observations_.end() || it->generation != session_.Generation() ||
|
||||
it->epoch != epoch_ || Age(it->created) > 30000)
|
||||
throw std::invalid_argument("STALE_OBSERVATION");
|
||||
return *it;
|
||||
}
|
||||
void StartAction(const json &j, const json &id) {
|
||||
Fields(j, {"id", "method", "operation_id", "observation_id", "action"});
|
||||
const auto operation = String(j, "operation_id");
|
||||
const auto arguments = json{
|
||||
{"observation_id", j.at("observation_id")},
|
||||
{"action",
|
||||
j.at("action")}}.dump();
|
||||
for (const auto &r : records_)
|
||||
if (r.operation == operation) {
|
||||
if (r.arguments != arguments)
|
||||
throw std::invalid_argument("OPERATION_ID_CONFLICT");
|
||||
Reply(id, r.result);
|
||||
return;
|
||||
}
|
||||
if (!allowed_ || paused_ || !session_.Status()["control"].get<bool>())
|
||||
throw std::invalid_argument("CONTROL_DENIED");
|
||||
if (!session_.Connected() || !frame_)
|
||||
throw std::invalid_argument("NOT_READY");
|
||||
if (action_)
|
||||
throw std::invalid_argument("BUSY");
|
||||
const auto &o = Observe(j);
|
||||
const auto &a = j.at("action");
|
||||
const auto type = String(a, "type", 32);
|
||||
auto running = std::make_unique<RunningAction>();
|
||||
running->id = id;
|
||||
running->operation = operation;
|
||||
running->generation = o.generation;
|
||||
running->epoch = o.epoch;
|
||||
auto point = [&](const json &p, int delay, int flag = 0, int wheel = 0) {
|
||||
auto coordinate = [&](const char *key, int size, int offset, int span,
|
||||
int source) {
|
||||
if (!p.contains(key) || !p[key].is_number())
|
||||
throw std::invalid_argument("INVALID_COORDINATE");
|
||||
const auto value = p[key].get<double>();
|
||||
if (!std::isfinite(value) || value < 0 || value >= size)
|
||||
throw std::invalid_argument("INVALID_COORDINATE");
|
||||
return float(std::clamp((offset + (value + .5) * span / size) / source,
|
||||
0.0, 1.0));
|
||||
};
|
||||
Step step;
|
||||
step.delay = delay;
|
||||
step.flag = flag;
|
||||
step.wheel = wheel;
|
||||
step.x =
|
||||
coordinate("x", o.width, o.region.x, o.region.width, o.source_width);
|
||||
step.y = coordinate("y", o.height, o.region.y, o.region.height,
|
||||
o.source_height);
|
||||
running->steps.push_back(std::move(step));
|
||||
};
|
||||
auto key = [&](int code, bool down, int delay) {
|
||||
Step step;
|
||||
step.kind = 1;
|
||||
step.key = code;
|
||||
step.down = down;
|
||||
step.delay = delay;
|
||||
running->steps.push_back(std::move(step));
|
||||
};
|
||||
if (type == "move" || type == "click" || type == "double_click") {
|
||||
Fields(a, type == "move"
|
||||
? std::initializer_list<const char *>{"type", "x", "y"}
|
||||
: std::initializer_list<const char *>{"type", "x", "y",
|
||||
"button"});
|
||||
point(a, 0);
|
||||
if (type != "move") {
|
||||
const auto button =
|
||||
a.contains("button") ? String(a, "button", 8) : "left";
|
||||
const int flag = button == "left" ? 1
|
||||
: button == "right" ? 3
|
||||
: button == "middle" ? 5
|
||||
: -1;
|
||||
if (flag < 0)
|
||||
throw std::invalid_argument("INVALID_BUTTON");
|
||||
point(a, 10, flag);
|
||||
point(a, 40, flag + 1);
|
||||
if (type == "double_click") {
|
||||
point(a, 110, flag);
|
||||
point(a, 140, flag + 1);
|
||||
}
|
||||
}
|
||||
} else if (type == "scroll") {
|
||||
Fields(a, {"type", "x", "y", "direction", "ticks"});
|
||||
const auto direction = String(a, "direction", 8);
|
||||
if (direction != "up" && direction != "down" && direction != "left" &&
|
||||
direction != "right")
|
||||
throw std::invalid_argument("INVALID_DIRECTION");
|
||||
const int ticks = Number(a, "ticks", 1, 10, 1);
|
||||
point(a, 0);
|
||||
point(a, 10, direction == "left" || direction == "right" ? 8 : 7,
|
||||
(direction == "up" || direction == "right" ? 120 : -120) * ticks);
|
||||
} else if (type == "drag") {
|
||||
Fields(a, {"type", "path", "duration_ms"});
|
||||
const auto &path = a.at("path");
|
||||
if (!path.is_array() || path.size() < 2 || path.size() > 128)
|
||||
throw std::invalid_argument("INVALID_PATH");
|
||||
const int duration = Number(a, "duration_ms", 100, 2000, 500);
|
||||
for (const auto &p : path)
|
||||
Fields(p, {"x", "y"});
|
||||
point(path.front(), 0);
|
||||
point(path.front(), 10, 1);
|
||||
for (size_t i = 1; i < path.size(); ++i)
|
||||
point(path[i], 10 + int(i * duration / (path.size() - 1)));
|
||||
point(path.back(), duration + 30, 2);
|
||||
} else if (type == "key_press") {
|
||||
Fields(a, {"type", "key"});
|
||||
const auto code = KeyCode(String(a, "key", 16));
|
||||
key(code, true, 0);
|
||||
key(code, false, 40);
|
||||
} else if (type == "hotkey") {
|
||||
Fields(a, {"type", "keys"});
|
||||
const auto &keys = a.at("keys");
|
||||
if (!keys.is_array() || keys.size() < 2 || keys.size() > 6)
|
||||
throw std::invalid_argument("INVALID_KEYS");
|
||||
std::set<int> seen;
|
||||
std::vector<int> codes;
|
||||
for (const auto &item : keys) {
|
||||
if (!item.is_string())
|
||||
throw std::invalid_argument("INVALID_KEY");
|
||||
int code = KeyCode(item.get<std::string>());
|
||||
if (!seen.insert(code).second)
|
||||
throw std::invalid_argument("DUPLICATE_KEY");
|
||||
codes.push_back(code);
|
||||
}
|
||||
int delay = 0;
|
||||
for (int code : codes) {
|
||||
key(code, true, delay);
|
||||
delay += 15;
|
||||
}
|
||||
delay += 30;
|
||||
for (auto it = codes.rbegin(); it != codes.rend(); ++it) {
|
||||
key(*it, false, delay);
|
||||
delay += 15;
|
||||
}
|
||||
} else if (type == "type_text") {
|
||||
Fields(a, {"type", "text"});
|
||||
if (!session_.Status()["text_input"].get<bool>())
|
||||
throw std::invalid_argument("TEXT_INPUT_UNSUPPORTED");
|
||||
Step step;
|
||||
step.kind = 2;
|
||||
step.text = String(a, "text", 16384);
|
||||
running->steps.push_back(std::move(step));
|
||||
} else
|
||||
throw std::invalid_argument("UNSUPPORTED_ACTION");
|
||||
while (records_.size() >= 128 ||
|
||||
(!records_.empty() && Age(records_.front().created) > 300000))
|
||||
records_.pop_front();
|
||||
records_.push_back(
|
||||
{operation,
|
||||
arguments,
|
||||
{{"status", "in_progress"}, {"operation_id", operation}},
|
||||
Clock::now()});
|
||||
running->started = Clock::now();
|
||||
action_ = std::move(running);
|
||||
}
|
||||
std::string Password() {
|
||||
if (password_fd_ < 3 || password_fd_ > 1024)
|
||||
throw std::invalid_argument("CREDENTIAL_CHANNEL_INVALID");
|
||||
std::string secret;
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (!interrupted && Clock::now() < deadline) {
|
||||
if (!WaitReadable(password_fd_, 10))
|
||||
continue;
|
||||
char c;
|
||||
const auto n = Read(password_fd_, &c, 1);
|
||||
if (!n || (n == 1 && c == '\n'))
|
||||
return secret;
|
||||
if (n < 0) {
|
||||
if (errno == EINTR || errno == EAGAIN)
|
||||
continue;
|
||||
throw std::invalid_argument("CREDENTIAL_CHANNEL_FAILED");
|
||||
}
|
||||
if (!c || secret.size() >= 256)
|
||||
throw std::invalid_argument("INVALID_CREDENTIAL");
|
||||
secret += c;
|
||||
}
|
||||
std::fill(secret.begin(), secret.end(), '\0');
|
||||
throw std::invalid_argument("CREDENTIAL_TIMEOUT");
|
||||
}
|
||||
bool Handle(const json &j) {
|
||||
json id = nullptr;
|
||||
try {
|
||||
if (!j.is_object() || !j.contains("id") || !j["id"].is_number_integer() ||
|
||||
j["id"].get<int64_t>() < 0)
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
id = j["id"];
|
||||
const auto method = String(j, "method", 32);
|
||||
if (method == "hello") {
|
||||
Fields(j, {"id", "method", "protocol"});
|
||||
if (String(j, "protocol") != "crossdesk-automation/1")
|
||||
throw std::invalid_argument("VERSION_MISMATCH");
|
||||
hello_ = true;
|
||||
Reply(id, {{"protocol", "crossdesk-automation/1"},
|
||||
{"image_formats", {"image/png"}},
|
||||
{"video_protocols", {"h264-annexb/1", "nv12/1"}},
|
||||
{"video_decoders", {"auto", "software"}},
|
||||
{"video_transport", "loopback-websocket/1"},
|
||||
{"action_completion", "submitted"},
|
||||
{"max_request_bytes", 65536},
|
||||
{"max_response_bytes", kMaxResponse},
|
||||
{"operation_cache_entries", 128},
|
||||
{"operation_cache_ms", 300000},
|
||||
{"human_pause", true}});
|
||||
return false;
|
||||
}
|
||||
if (!hello_)
|
||||
throw std::invalid_argument("HELLO_REQUIRED");
|
||||
if (method == "connect") {
|
||||
Fields(j, {"id", "method", "device", "display", "mode",
|
||||
"decode_path", "decoder"});
|
||||
if (connected_once_)
|
||||
throw std::invalid_argument("NEW_PROCESS_REQUIRED");
|
||||
const auto device = String(j, "device", 9);
|
||||
if (device.size() != 9 ||
|
||||
device.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("INVALID_DEVICE");
|
||||
const auto mode = j.contains("mode") ? String(j, "mode", 8) : "observe";
|
||||
if (mode != "observe" && mode != "control")
|
||||
throw std::invalid_argument("INVALID_MODE");
|
||||
options_.display = Number(j, "display", 0, 63, 0);
|
||||
const auto path = j.contains("decode_path")
|
||||
? String(j, "decode_path", 16) : "browser";
|
||||
const auto decoder = j.contains("decoder")
|
||||
? String(j, "decoder", 16) : "auto";
|
||||
if ((path != "browser" && path != "minirtc") ||
|
||||
(decoder != "auto" && decoder != "software"))
|
||||
throw std::invalid_argument("INVALID_VIDEO_CONFIG");
|
||||
decode_path_ = path;
|
||||
decoder_ = decoder;
|
||||
options_.hardware_acceleration = decoder != "software";
|
||||
auto password = Password();
|
||||
connected_once_ = true;
|
||||
paused_ = false;
|
||||
allowed_ = mode == "control";
|
||||
session_.Connect(options_, device, std::move(password));
|
||||
session_.SetControl(allowed_);
|
||||
} else if (method == "screenshot" || method == "preview") {
|
||||
CaptureFrame(j, id, method == "preview");
|
||||
return false;
|
||||
} else if (method == "action") {
|
||||
StartAction(j, id);
|
||||
return false;
|
||||
} else if (method == "control") {
|
||||
Fields(j, {"id", "method", "enabled"});
|
||||
const bool enable = Flag(j, "enabled");
|
||||
if (enable && (!allowed_ || paused_))
|
||||
throw std::invalid_argument("CONTROL_DENIED");
|
||||
if (!enable)
|
||||
FinishAction("CONTROL_DISABLED");
|
||||
session_.SetControl(enable);
|
||||
} else if (method == "viewer_control") {
|
||||
Fields(j, {"id", "method", "operation"});
|
||||
const auto operation = String(j, "operation", 16);
|
||||
if (operation == "pause") {
|
||||
paused_ = true;
|
||||
FinishAction("USER_PAUSED");
|
||||
session_.SetControl(false);
|
||||
} else if (operation == "resume") {
|
||||
if (!allowed_)
|
||||
throw std::invalid_argument("CONTROL_DENIED");
|
||||
paused_ = false;
|
||||
session_.SetControl(true);
|
||||
} else if (operation == "disconnect") {
|
||||
Stop("CANCELLED");
|
||||
session_.Disconnect();
|
||||
} else
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
} else if (method == "display") {
|
||||
Fields(j, {"id", "method", "index"});
|
||||
Stop("SESSION_CHANGED");
|
||||
if (!session_.SelectDisplay(Number(j, "index", 0, 63, -1)))
|
||||
throw std::invalid_argument("DISPLAY_UNAVAILABLE");
|
||||
frame_.reset();
|
||||
++epoch_;
|
||||
} else if (method == "cancel") {
|
||||
Fields(j, {"id", "method", "request_id"});
|
||||
if (!j.contains("request_id") || !j["request_id"].is_number_integer())
|
||||
throw std::invalid_argument("INVALID_REQUEST");
|
||||
const auto target = j["request_id"];
|
||||
if (action_ && action_->id == target)
|
||||
FinishAction("CANCELLED");
|
||||
if (encoding_ && !encoding_->cancelled &&
|
||||
encoding_->capture.id == target) {
|
||||
encoding_->cancelled = true;
|
||||
Error(target, "CANCELLED");
|
||||
}
|
||||
for (auto it = captures_.begin(); it != captures_.end();) {
|
||||
if (it->id == target) {
|
||||
Error(target, "CANCELLED");
|
||||
it = captures_.erase(it);
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
} else if (method == "disconnect" || method == "exit") {
|
||||
Fields(j, {"id", "method"});
|
||||
Stop("CANCELLED");
|
||||
session_.Disconnect();
|
||||
frame_.reset();
|
||||
} else if (method == "video_open") {
|
||||
Fields(j, {"id", "method", "ticket", "view", "view_generation",
|
||||
"session", "origin", "apps"});
|
||||
if (!video_) throw std::invalid_argument("VIDEO_CHANNEL_UNAVAILABLE");
|
||||
if (!session_.Connected()) throw std::invalid_argument("NOT_READY");
|
||||
PumpVideo();
|
||||
video_->Grant({String(j, "ticket", 32), String(j, "view", 32),
|
||||
String(j, "session", 32), String(j, "origin", 256),
|
||||
Sequence(j, "view_generation"), Flag(j, "apps")});
|
||||
Reply(id, {{"url", video_->Url()}, {"transport", "loopback-websocket/1"},
|
||||
{"protocol", decode_path_ == "minirtc" ? "nv12/1" : "h264-annexb/1"},
|
||||
{"decoder", decoder_}, {"expires_in_ms", 15000}});
|
||||
return false;
|
||||
} else if (method == "video_revoke") {
|
||||
Fields(j, {"id", "method", "view"});
|
||||
if (video_) video_->Revoke(j.contains("view") ? String(j, "view", 32) : "");
|
||||
} else if (method == "status")
|
||||
Fields(j, {"id", "method"});
|
||||
else
|
||||
throw std::invalid_argument("UNSUPPORTED_METHOD");
|
||||
PumpVideo();
|
||||
Reply(id, Status());
|
||||
return method == "exit";
|
||||
} catch (const std::invalid_argument &e) {
|
||||
Error(id, e.what());
|
||||
} catch (const json::exception &) {
|
||||
Error(id, "INVALID_REQUEST");
|
||||
} catch (...) {
|
||||
Error(id, "OPERATION_FAILED");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ControllerSession session_;
|
||||
Responses output_;
|
||||
SessionOptions options_;
|
||||
std::string decode_path_ = "browser", decoder_ = "auto";
|
||||
int password_fd_, output_fd_ = -1;
|
||||
bool hello_ = false, connected_once_ = false, allowed_ = false,
|
||||
paused_ = false;
|
||||
uint64_t epoch_ = 0, observation_sequence_ = 0;
|
||||
std::shared_ptr<const DecodedFrame> frame_;
|
||||
std::deque<Observation> observations_;
|
||||
std::deque<Capture> captures_;
|
||||
std::unique_ptr<Encoding> encoding_;
|
||||
std::unique_ptr<RunningAction> action_;
|
||||
std::deque<Record> records_;
|
||||
json preview_;
|
||||
int preview_edge_ = 960;
|
||||
Clock::time_point preview_time_;
|
||||
std::unique_ptr<VideoServer> video_;
|
||||
uint64_t video_generation_ = 0, last_video_frame_ = 0;
|
||||
bool video_connected_ = false, video_enabled_ = false;
|
||||
};
|
||||
} // namespace
|
||||
int Agent(int output_fd, SessionOptions options, int password_fd,
|
||||
bool video_websocket) {
|
||||
Automation automation(output_fd, std::move(options), password_fd, video_websocket);
|
||||
automation.SetOutputFd(output_fd);
|
||||
return automation.Run();
|
||||
}
|
||||
} // namespace crossdesk::cli
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-02
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _AGENT_H_
|
||||
#define _AGENT_H_
|
||||
|
||||
#include <controller_session.h>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
int Agent(int output_fd, SessionOptions options, int password_fd,
|
||||
bool video_websocket = false);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,110 @@
|
||||
# Native automation protocol
|
||||
|
||||
`crossdesk-cli agent --stdio --password-fd 3 --video-ws` is the private native adapter used by CrossDesk MCP. It is separate from the existing terminal-oriented `bridge` protocol. This is **not MCP on stdin**: the TypeScript service owns the public MCP transport and tool schemas.
|
||||
|
||||
Optional native arguments are `--server HOST`, `--port PORT`, `--quality PRESET` and `--log-dir DIRECTORY`. The caller creates the log directory. Diagnostic stdout is redirected to stderr; only protocol responses use the preserved output descriptor. Passwords travel over descriptor 3, terminated by newline or EOF, and never appear in JSON requests. `--video-ws` starts an authenticated, native loopback WebSocket listener on an ephemeral port. There is no video file descriptor. Agent mode selects the high-frame-rate preview tier at up to 1920 × 1080 by default; negotiated remote capabilities and network conditions determine the delivered rate. Each process accepts one connection attempt; retry authentication with a new process and credential pipe.
|
||||
|
||||
## Envelope
|
||||
|
||||
One UTF-8 JSON object per line, maximum request 64 KiB and nesting depth 32. Use nonnegative integer request IDs. Responses may complete out of order; correlate by ID.
|
||||
|
||||
```json
|
||||
{"id":1,"method":"hello","protocol":"crossdesk-automation/1"}
|
||||
{"id":1,"ok":true,"result":{"protocol":"crossdesk-automation/1","image_formats":["image/png"],"action_completion":"submitted","human_pause":true,"max_request_bytes":65536,"max_response_bytes":4194304,"operation_cache_entries":128,"operation_cache_ms":300000}}
|
||||
```
|
||||
|
||||
Unknown fields and malformed arguments are rejected. Error responses contain a stable code, retryability and whether input may already have been submitted:
|
||||
|
||||
```json
|
||||
{"id":2,"ok":false,"error":{"code":"CONTROL_DENIED","retryable":false,"side_effect_possible":false}}
|
||||
```
|
||||
|
||||
The output queue is bounded to 64 messages and 8 MiB; a blocked output pipe terminates after 10 seconds without progress. EOF, signals and `exit` disconnect and release held input. A caller that needs responses must keep stdin open until they arrive.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Parameters in addition to `id`/`method` |
|
||||
| --- | --- |
|
||||
| `hello` | `protocol: "crossdesk-automation/1"` |
|
||||
| `connect` | `device` (9 digits), `mode: "observe" | "control"`, `display` (0–63), optional `decode_path: "browser" / "minirtc"`, `decoder: "auto" / "software"` |
|
||||
| `status` | none |
|
||||
| `screenshot` | optional `region: {x,y,width,height}`, `max_edge` (64–2048), `after_frame_id`, `generation`, `timeout_ms` (0–5000) |
|
||||
| `video_open` | `ticket`, `view`, `view_generation`, `session`, `origin`, `apps`; requires `--video-ws` and a connected session |
|
||||
| `video_revoke` | optional `view`; omit to revoke all video grants and sockets |
|
||||
| `preview` | optional `max_edge` (64–960), `after_frame_id`, `generation`, `timeout_ms` (0–5000); no region |
|
||||
| `action` | `operation_id`, `observation_id`, `action` |
|
||||
| `control` | `enabled` boolean; cannot override observe-only permission or human pause |
|
||||
| `viewer_control` | `operation: "pause" | "resume" | "disconnect"`; restricted to trusted UI by the MCP adapter |
|
||||
| `display` | `index` (0–63) |
|
||||
| `cancel` | `request_id`; cancels queued/in-flight captures or input sequences |
|
||||
| `disconnect`, `exit` | none; `exit` also ends the process |
|
||||
|
||||
`connect` returns immediately after initiating transport. Poll `status` until `state == "ready"`; this requires a frame from the current display generation. Status includes `transport_state`, `geometry_epoch`, `frame_age_ms`, `source_size`, `control_allowed`, `human_paused`, host platform, displays and transport diagnostics.
|
||||
|
||||
## Live video
|
||||
|
||||
`doctor --json` advertises `automation_video_transport: "loopback-websocket/1"`, `automation_video_protocols: ["h264-annexb/1", "nv12/1"]`, and `automation_video_decoders: ["auto", "software"]`. `hello` repeats these without the `automation_` prefix. The MCP adapter requires the transport capability, so an old pipe-only CLI is rejected. Node spawns only stdin, stdout, stderr, and the credential pipe; live video never enters Node.
|
||||
|
||||
`connect.decode_path` defaults to `browser` (compressed H.264), or selects `minirtc` (decoded NV12). `connect.decoder` defaults to `auto` (hardware preferred, software when unavailable); `software` selects the native software decoder. Both paths maintain native screenshot frames with the selected decoder. These choices are fixed for the connection. Status exposes `video` and the reported `native_video_decoder` backend name. PNG encoding runs only when requested. The legacy `preview` image method remains available to private protocol callers but is not used by MCP panels.
|
||||
|
||||
### Authorization and transport
|
||||
|
||||
The trusted adapter generates 24 random bytes as a 32-character base64url `ticket` and sends `video_open` over private JSON-RPC. `view` and `session` are also 32-character base64url IDs; `view_generation` is positive. `origin` is the Node panel's exact `http://127.0.0.1:PORT` origin. `apps` permits an opaque (`null`) or HTTPS MCP Apps origin, but still requires the same one-time ticket. No password is included.
|
||||
|
||||
The response contains `{url, transport, protocol, decoder, expires_in_ms:15000}`. Node supplies the ticket to the authorized UI. The browser opens the returned `ws://127.0.0.1:PORT/api/video` directly and sends `{"ticket":"..."}` as its first text message within 3 seconds. Tickets expire after 15 seconds, are consumed once, and are bound to a view/session plus the native display generation. The native service checks the exact Host and path, RFC 6455 version, and authorized Origin. It does not serve HTTP content or accept remote input commands. Logs are disabled for this listener; tickets never go in URLs or model-visible results.
|
||||
|
||||
The listener uses its own I/O thread, at most 16 WebSocket candidates and 8 authorized viewers. Client messages are limited to 1024 bytes. Revocation, disconnect, native generation changes, or shutdown cancel sockets and invalidate tickets, including partially sent frames. Pause only disables Agent control; viewing continues. The adapter explicitly revokes a removed or replaced view, and terminates the native session if revocation cannot be confirmed. Native processes own ephemeral ports, so panel CSP allows `ws://127.0.0.1:*`, restricted to the literal loopback address; the control HTTP origin remains exact.
|
||||
|
||||
### Binary messages and flow control
|
||||
|
||||
Each binary WebSocket message contains one complete frame, **without the old pipe length prefix**. Integers are big-endian. For `h264-annexb/1`:
|
||||
|
||||
| Offset | Type | Value |
|
||||
| --- | --- | --- |
|
||||
| 0 | u32 | `0x43445631` (`CDV1`) |
|
||||
| 4 | u32 | Display/connection generation |
|
||||
| 8 | u32 | Receive sequence; gaps require a new keyframe |
|
||||
| 12 | u64 | Local monotonic receive timestamp in microseconds |
|
||||
| 20 | u32 | Flags: 1 = IDR keyframe, 0 = delta |
|
||||
| 24 | bytes | Annex B H.264; keyframes include SPS and PPS |
|
||||
|
||||
Access units are capped at 4 MiB. Both the receive tap and the I/O handoff are bounded to 60 frames / 8 MiB. Overflow discards dependent frames until an IDR. After receiving each message the browser sends `{"type":"ack","generation":N,"sequence":N}` directly to the native socket, in order. This acknowledges receipt, not decode, presentation, or remote execution. Per-viewer unacknowledged output is capped at 8 frames; at 1 MiB outstanding, further frames are skipped until space is available (a single keyframe may exceed that threshold). Dropping a dependent picture requires a new keyframe; `{"type":"keyframe"}` requests recovery, rate-limited to once per second.
|
||||
|
||||
For `nv12/1`, magic is `0x43444e31` (`CDN1`). Offsets 4, 8, 12 and 20 remain generation, decoded sequence, local monotonic decoded-frame queue time in microseconds, and flags (always 1). Offsets 24 and 28 are u32 width and height; offset 32 starts tightly packed Y followed by interleaved UV. Dimensions must be even, at most 8192 per axis / 16777216 pixels. Message size must equal `32 + width * height * 3 / 2` (at most 25165856 bytes). Color is limited-range BT.601, matching the existing PNG conversion. Sequence gaps are allowed.
|
||||
|
||||
NV12 retains one latest picture in the I/O handoff and at most one unacknowledged picture per viewer. New pictures are skipped for a slow viewer without blocking others. Each prepared WebSocket message is immutable and shared between recipients, avoiding per-viewer payload copies. There is still a native copy into the WebSocket payload and raw pixel transfer to the browser; this is not GPU zero-copy. A viewer whose oldest outstanding frame remains unacknowledged for 10 seconds is disconnected. Hidden/unloaded pages close their native socket. The browser replaces pending raw pictures and does not decode H.264 in this mode.
|
||||
|
||||
`status.video_output` includes `transport`, `viewers`, I/O handoff `queued` / `queued_bytes`, aggregate `unacknowledged_bytes`, and `dropped` (handoff replacements plus per-viewer congestion skips). These diagnostics contain no video payload. The original receive-tap SPS/PPS and keyframe state remain available for H.264.
|
||||
|
||||
The MiniRTC `Params` structure appends `on_receive_encoded_video`, changing its ABI size. Rebuild consumers and MiniRTC together; zero-initialize `Params`. The callback borrows frame bytes on the receive/decode queue and must not retain the pointer. Returning nonzero requests a remote keyframe. No remote wire-protocol change is required.
|
||||
|
||||
## Images and observations
|
||||
|
||||
Images return `{mime_type:"image/png",data:"<base64>",metadata:{...}}`. Metadata includes `generation`, `geometry_epoch`, `frame_id`, `display_index`, `source_size`, `source_rect`, `image_size` and `frame_age_ms`; only screenshots create `observation_id`. Preview images cannot authorize model input.
|
||||
|
||||
NV12 frames must be tightly packed, even dimensions, at most 8192 per axis and 16 million pixels. Conversion/PNG encoding runs on one worker with no `ControllerSession` calls. The owner loop continues processing cancellation and input release. Screenshots have priority over queued previews. An encoding from an obsolete generation or geometry epoch is discarded. Frames older than two seconds are not used for a new image. PNG limits are 2 MiB for observations and 256 KiB for previews; the encoder reduces dimensions when necessary.
|
||||
|
||||
`after_frame_id` requires `generation`. A screenshot waits up to `timeout_ms` for a newer frame; a preview can return `{unchanged:true, frame_age_ms, stale, generation, frame_id}`. Neither path treats an image as an input acknowledgement.
|
||||
|
||||
## Actions
|
||||
|
||||
All coordinates refer to the immutable returned observation image. The native layer maps them through its source rectangle to normalized remote coordinates, without terminal layout padding. Coordinates must satisfy `0 <= x < image_width` and `0 <= y < image_height`. Observations expire after 30 seconds, retain at most 32 entries, and are invalidated by geometry/display generation changes.
|
||||
|
||||
```json
|
||||
{"id":10,"method":"action","operation_id":"click-1","observation_id":"obs_2_1","action":{"type":"click","x":120,"y":80,"button":"left"}}
|
||||
```
|
||||
|
||||
Supported payloads:
|
||||
|
||||
- `move`: `x`, `y`.
|
||||
- `click`, `double_click`: `x`, `y`, optional `button` (`left`, `right`, `middle`).
|
||||
- `scroll`: `x`, `y`, `direction` (`up`, `down`, `left`, `right`), optional `ticks` (1–10).
|
||||
- `drag`: `path` of 2–128 `{x,y}` points, optional `duration_ms` (100–2000).
|
||||
- `key_press`: `key`; `hotkey`: 2–6 unique `keys`.
|
||||
- `type_text`: `text`, at most 16 KiB UTF-8; requires negotiated remote text-input capability.
|
||||
|
||||
Only one input sequence runs at once. Timed steps are advanced by the owner loop; cancellation, disconnect, display changes and pause release held buttons/keys. A failed send is conservatively considered potentially submitted.
|
||||
|
||||
Results use `status: submitted | in_progress | failed_before_submit | outcome_unknown`, with `operation_id`, `retryable`, `side_effect_possible` and, for completed actions, `generation`/`after_frame_id`. The same operation ID and arguments retrieve the cached result; different arguments conflict. Retention is bounded to 128 entries, with entries older than five minutes eligible for removal on a new action. Never retry an uncertain effect with a new ID. `submitted` does not claim remote execution.
|
||||
|
||||
The native process trusts its parent. The parent must authenticate panel-only `viewer_control` calls and protect descriptor 3; this private pipe does not isolate arbitrary programs running as the same OS user.
|
||||
@@ -7,6 +7,8 @@ CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启
|
||||
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
||||
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
||||
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
|
||||
- MCP 实时面板新增 MiniRTC 的 `on_receive_encoded_video` 回调,转发解码前的完整 H.264 Annex B 帧;回调及关键帧请求由当前子模块源码提供。`Params` 新增字段改变 ABI 大小,CLI 与 MiniRTC 必须一起重建;发布时同步记录包含该改动的子模块提交。
|
||||
- 自动化图像编码复用 MiniRTC 已有的 libyuv 2025.8.14,并通过 Xmake 固定静态 libpng v1.6.59;libpng 的压缩依赖沿用平台 zlib。新增依赖的许可证和对应源码也应纳入正式发行的依赖材料。
|
||||
|
||||
MiniRTC 已在提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 中合入凭据日志脱敏:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 仅按主仓库记录初始化子模块,不再应用独立补丁;更新 MiniRTC 时只需更新子模块提交。共享子模块中的脱敏同样适用于桌面程序;该修改没有改变鉴权、TLS、TURN 或媒体协议。
|
||||
|
||||
|
||||
@@ -183,6 +183,6 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
|
||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
||||
|
||||
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
|
||||
终端模式无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker 或 IP 直连。终端宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端。Agent 的 PNG 截图和结构化操作由独立的 [自动化模式](automation-protocol.md) 和 [MCP 服务](../../mcp/README.md) 提供。
|
||||
|
||||
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
||||
|
||||
+24
-8
@@ -1,4 +1,5 @@
|
||||
#include "terminal_io.h"
|
||||
#include "agent.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <clocale>
|
||||
@@ -1034,6 +1035,7 @@ const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|ki
|
||||
crossdesk-cli view <9-digit-device-id> [options] (compatibility alias)
|
||||
crossdesk-cli doctor [--json]
|
||||
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
|
||||
crossdesk-cli agent --stdio [--password-fd 3] [--video-ws] [--server host] [--port 9099]
|
||||
|
||||
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
|
||||
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
|
||||
@@ -1053,6 +1055,7 @@ Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers re
|
||||
Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged.
|
||||
Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS.
|
||||
bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor.
|
||||
agent: crossdesk-automation/1, structured actions and PNG screenshots; no terminal required.
|
||||
No IP direct connection, audio playback, clipboard or file transfer support.
|
||||
)";
|
||||
} // namespace
|
||||
@@ -1087,12 +1090,17 @@ int main(int argc, char **argv) {
|
||||
{"kitty", "probe_on_view"},
|
||||
{"sixel", "probe_on_view"},
|
||||
{"audio_playback", false},
|
||||
{"bridge_version", 1}};
|
||||
{"bridge_version", 1},
|
||||
{"automation_protocol", "crossdesk-automation/1"},
|
||||
{"automation_video", "h264-annexb/1"},
|
||||
{"automation_video_transport", "loopback-websocket/1"},
|
||||
{"automation_video_protocols", {"h264-annexb/1", "nv12/1"}},
|
||||
{"automation_video_decoders", {"auto", "software"}}};
|
||||
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
||||
return 0;
|
||||
}
|
||||
int i = 2;
|
||||
if (command != "view" && command != "bridge") {
|
||||
if (command != "view" && command != "bridge" && command != "agent") {
|
||||
if (command.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument(
|
||||
"expected a 9-digit device ID or a command; use --help");
|
||||
@@ -1105,6 +1113,8 @@ int main(int argc, char **argv) {
|
||||
double zoom = 1, cell_aspect = 0;
|
||||
bool control = false, stdio = false, native = false, fps_explicit = false;
|
||||
int secret_fd = 3;
|
||||
bool video_websocket = false;
|
||||
if (command == "agent") options.fps = 30;
|
||||
if (command == "view") {
|
||||
if (i >= argc)
|
||||
throw std::invalid_argument("device ID required");
|
||||
@@ -1124,8 +1134,10 @@ int main(int argc, char **argv) {
|
||||
std::string a = argv[i];
|
||||
if (a == "--control" && command == "view")
|
||||
control = true;
|
||||
else if (a == "--stdio" && command == "bridge")
|
||||
else if (a == "--stdio" && command != "view")
|
||||
stdio = true;
|
||||
else if (a == "--video-ws" && command == "agent")
|
||||
video_websocket = true;
|
||||
else {
|
||||
if (i + 1 >= argc)
|
||||
throw std::invalid_argument("option value missing");
|
||||
@@ -1134,6 +1146,8 @@ int main(int argc, char **argv) {
|
||||
options.host = value;
|
||||
else if (a == "--port")
|
||||
options.port = number(value, 1, 65535);
|
||||
else if (a == "--log-dir" && command == "agent")
|
||||
options.log_directory = value;
|
||||
else if (a == "--fps" && command == "view") {
|
||||
options.fps = number(value, 1, 30);
|
||||
fps_explicit = true;
|
||||
@@ -1158,7 +1172,7 @@ int main(int argc, char **argv) {
|
||||
} else if (a == "--zoom" && command == "view") {
|
||||
native = value == "native";
|
||||
zoom = native ? 1 : number(value, 1, 32);
|
||||
} else if (a == "--password-fd" && command == "bridge")
|
||||
} else if (a == "--password-fd" && command != "view")
|
||||
secret_fd = number(value, 3, 1024);
|
||||
else
|
||||
throw std::invalid_argument("unsupported option");
|
||||
@@ -1167,14 +1181,14 @@ int main(int argc, char **argv) {
|
||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
||||
renderer != "sixel")
|
||||
throw std::invalid_argument("unknown renderer");
|
||||
if (command == "bridge" && !stdio)
|
||||
throw std::invalid_argument("bridge requires --stdio");
|
||||
if (command != "view" && !stdio)
|
||||
throw std::invalid_argument("automation and bridge require --stdio");
|
||||
Descriptor output(Duplicate(1));
|
||||
if (output.value < 0)
|
||||
throw std::runtime_error("cannot preserve output channel");
|
||||
// All library stdout writes go to diagnostics. Only Output uses the saved
|
||||
// fd.
|
||||
options.log_directory = Logs();
|
||||
if (options.log_directory.empty()) options.log_directory = Logs();
|
||||
if (command == "view") {
|
||||
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
||||
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
||||
@@ -1193,7 +1207,9 @@ int main(int argc, char **argv) {
|
||||
return command == "view" ? View(options, device, renderer, control,
|
||||
output.value, detail, zoom, native,
|
||||
cell_aspect, fps_explicit)
|
||||
: Bridge(output.value, options, secret_fd);
|
||||
: command == "agent"
|
||||
? Agent(output.value, options, secret_fd, video_websocket)
|
||||
: Bridge(output.value, options, secret_fd);
|
||||
} catch (const std::exception &e) {
|
||||
if (command == "view") {
|
||||
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
|
||||
|
||||
+13
-11
@@ -36,17 +36,19 @@ function main() {
|
||||
return;
|
||||
}
|
||||
const stdio = ['inherit', 'inherit', 'inherit'];
|
||||
if (args[0] === 'bridge') {
|
||||
const index = args.lastIndexOf('--password-fd');
|
||||
const fd = index === -1 ? 3 : Number(args[index + 1]);
|
||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||
while (stdio.length <= fd) stdio.push('ignore');
|
||||
stdio[fd] = fd;
|
||||
}
|
||||
} catch { /* The native bridge reports a missing credential descriptor. */ }
|
||||
if (args[0] === 'bridge' || args[0] === 'agent') {
|
||||
for (const [option, fallback] of [['--password-fd', 3]]) {
|
||||
const index = args.lastIndexOf(option);
|
||||
const fd = index === -1 ? fallback : Number(args[index + 1]);
|
||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||
while (stdio.length <= fd) stdio.push('ignore');
|
||||
stdio[fd] = fd;
|
||||
}
|
||||
} catch { /* Native code reports unavailable descriptors. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
const child = spawn(binary, args, { stdio, shell: false });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chunkdash/crossdesk-cli",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "CrossDesk remote desktop controller for the terminal",
|
||||
"license": "GPL-3.0-only",
|
||||
|
||||
@@ -84,6 +84,8 @@ function inspect(binary, target) {
|
||||
}
|
||||
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
|
||||
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
|
||||
if (doctor.automation_protocol !== 'crossdesk-automation/1')
|
||||
throw new Error('Native binary does not report the expected automation protocol.');
|
||||
return info;
|
||||
}
|
||||
function pack(directory, output) {
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
#include "video_server.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <websocketpp/config/asio_no_tls.hpp>
|
||||
#include <websocketpp/server.hpp>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using namespace std::chrono_literals;
|
||||
using json = nlohmann::json;
|
||||
using Handle = websocketpp::connection_hdl;
|
||||
using Server = websocketpp::server<websocketpp::config::asio>;
|
||||
constexpr size_t kMaxEncoded = 4 * 1024 * 1024;
|
||||
constexpr size_t kMaxQueued = 8 * 1024 * 1024;
|
||||
|
||||
bool Token(const std::string &s) {
|
||||
return s.size() == 32 &&
|
||||
s.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvw"
|
||||
"xyz0123456789-_") == std::string::npos;
|
||||
}
|
||||
bool AppOrigin(const std::string &s) {
|
||||
return s == "null" ||
|
||||
(s.size() > 8 && s.size() <= 256 && s.compare(0, 8, "https://") == 0 &&
|
||||
s.find_first_of("/?#@\\ \t\r\n", 8) == std::string::npos);
|
||||
}
|
||||
bool BrowserOrigin(const std::string &s) {
|
||||
const std::string prefix = "http://127.0.0.1:";
|
||||
if (s.compare(0, prefix.size(), prefix) || s.size() > prefix.size() + 5 ||
|
||||
s.size() == prefix.size() ||
|
||||
s.find_first_not_of("0123456789", prefix.size()) != std::string::npos)
|
||||
return false;
|
||||
const auto port = std::stoul(s.substr(prefix.size()));
|
||||
return port > 0 && port <= 65535;
|
||||
}
|
||||
void Number(std::string &out, uint64_t value, int bytes) {
|
||||
for (int n = bytes - 1; n >= 0; --n)
|
||||
out.push_back(char(value >> (n * 8)));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct VideoServer::State {
|
||||
struct Ticket {
|
||||
VideoGrant grant;
|
||||
Clock::time_point expires;
|
||||
};
|
||||
struct Receipt {
|
||||
uint32_t generation, sequence;
|
||||
size_t bytes;
|
||||
Clock::time_point sent;
|
||||
};
|
||||
struct Client {
|
||||
std::string origin, view, session;
|
||||
uint64_t view_generation = 0;
|
||||
bool authorized = false, waiting_key = true;
|
||||
size_t bytes = 0;
|
||||
Clock::time_point opened = Clock::now(), pinged = Clock::now();
|
||||
std::deque<Receipt> receipts;
|
||||
};
|
||||
struct Packet {
|
||||
std::shared_ptr<const DecodedFrame> raw;
|
||||
EncodedVideoFrame encoded;
|
||||
size_t Size() const {
|
||||
return raw ? 32 + raw->nv12.size() : 24 + encoded.data.size();
|
||||
}
|
||||
uint64_t Generation() const {
|
||||
return raw ? raw->generation : encoded.generation;
|
||||
}
|
||||
uint64_t Sequence() const { return raw ? raw->frame_id : encoded.sequence; }
|
||||
bool Key() const { return raw || encoded.key; }
|
||||
};
|
||||
|
||||
Server server;
|
||||
Server::timer_ptr timer;
|
||||
std::thread thread;
|
||||
std::string authority;
|
||||
std::map<Handle, Client, std::owner_less<Handle>> clients;
|
||||
std::map<std::string, Ticket> tickets;
|
||||
std::atomic<size_t> viewers{0}, outstanding{0}, dropped{0};
|
||||
std::atomic<bool> key_requested{false};
|
||||
uint64_t generation = 0;
|
||||
bool connected = false, decoded = false;
|
||||
Clock::time_point last_key_request{};
|
||||
|
||||
// One posted drain, not one posted handler per picture. Raw frames replace
|
||||
// each other; the compressed handoff is bounded and recovers at an IDR.
|
||||
mutable std::mutex mutex;
|
||||
std::deque<Packet> queue;
|
||||
size_t queued_bytes = 0;
|
||||
bool scheduled = false, wait_key = true;
|
||||
|
||||
State() {
|
||||
server.clear_access_channels(websocketpp::log::alevel::all);
|
||||
server.clear_error_channels(websocketpp::log::elevel::all);
|
||||
server.init_asio();
|
||||
server.set_open_handshake_timeout(3000);
|
||||
server.set_close_handshake_timeout(1000);
|
||||
server.set_pong_timeout(10000);
|
||||
server.set_max_message_size(1024);
|
||||
server.set_max_http_body_size(0);
|
||||
server.set_socket_init_handler([](Handle, asio::ip::tcp::socket &socket) {
|
||||
asio::error_code ec;
|
||||
socket.set_option(asio::ip::tcp::no_delay(true), ec);
|
||||
socket.set_option(asio::socket_base::send_buffer_size(256 * 1024), ec);
|
||||
});
|
||||
server.set_validate_handler([this](Handle h) {
|
||||
auto c = server.get_con_from_hdl(h);
|
||||
if (clients.size() >= 16 || c->get_resource() != "/api/video" ||
|
||||
c->get_request_header("Host") != authority ||
|
||||
c->get_request_header("Sec-WebSocket-Version") != "13" ||
|
||||
c->get_origin().empty() || c->get_origin().size() > 256) {
|
||||
c->set_status(websocketpp::http::status_code::forbidden);
|
||||
return false;
|
||||
}
|
||||
Client client;
|
||||
client.origin = c->get_origin();
|
||||
clients.emplace(h, std::move(client));
|
||||
return true;
|
||||
});
|
||||
server.set_message_handler([this](Handle h, Server::message_ptr message) {
|
||||
try {
|
||||
Message(h, message);
|
||||
} catch (...) {
|
||||
Drop(h);
|
||||
}
|
||||
});
|
||||
server.set_close_handler([this](Handle h) { Remove(h); });
|
||||
server.set_fail_handler([this](Handle h) { Remove(h); });
|
||||
server.set_pong_timeout_handler(
|
||||
[this](Handle h, const std::string &) { Drop(h); });
|
||||
server.listen(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0));
|
||||
asio::error_code ec;
|
||||
authority =
|
||||
"127.0.0.1:" + std::to_string(server.get_local_endpoint(ec).port());
|
||||
if (ec)
|
||||
throw std::runtime_error("VIDEO_CHANNEL_UNAVAILABLE");
|
||||
server.start_accept();
|
||||
Maintain();
|
||||
thread = std::thread([this] { server.run(); });
|
||||
}
|
||||
~State() {
|
||||
Call([this] {
|
||||
websocketpp::lib::error_code ec;
|
||||
server.stop_listening(ec);
|
||||
if (timer)
|
||||
timer->cancel();
|
||||
Revoke("");
|
||||
});
|
||||
server.stop();
|
||||
thread.join();
|
||||
}
|
||||
template <class F> void Call(F f) {
|
||||
auto task = std::make_shared<std::packaged_task<void()>>(std::move(f));
|
||||
auto result = task->get_future();
|
||||
server.get_io_service().post([task] { (*task)(); });
|
||||
result.get();
|
||||
}
|
||||
void Remove(Handle h) {
|
||||
auto it = clients.find(h);
|
||||
if (it == clients.end())
|
||||
return;
|
||||
if (it->second.authorized)
|
||||
--viewers;
|
||||
outstanding -= it->second.bytes;
|
||||
clients.erase(it);
|
||||
}
|
||||
void Drop(Handle h) {
|
||||
// Cancel even a partially sent raw picture on revocation. Waiting for a
|
||||
// WebSocket close frame behind megabytes of video would keep it alive.
|
||||
Remove(h);
|
||||
websocketpp::lib::error_code ec;
|
||||
auto c = server.get_con_from_hdl(h, ec);
|
||||
if (!ec) {
|
||||
asio::error_code ignored;
|
||||
c->get_raw_socket().cancel(ignored);
|
||||
c->get_raw_socket().close(ignored);
|
||||
}
|
||||
}
|
||||
void Revoke(const std::string &view) {
|
||||
for (auto it = tickets.begin(); it != tickets.end();) {
|
||||
if (view.empty() || it->second.grant.view == view)
|
||||
it = tickets.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
std::vector<Handle> close;
|
||||
for (const auto &[h, c] : clients)
|
||||
if (view.empty() || c.view == view)
|
||||
close.push_back(h);
|
||||
for (auto h : close)
|
||||
Drop(h);
|
||||
}
|
||||
void RequestKey() {
|
||||
const auto now = Clock::now();
|
||||
if (now - last_key_request < 1s)
|
||||
return;
|
||||
last_key_request = now;
|
||||
key_requested = true;
|
||||
}
|
||||
void Message(Handle h, const Server::message_ptr &message) {
|
||||
auto it = clients.find(h);
|
||||
if (it == clients.end())
|
||||
return;
|
||||
auto &c = it->second;
|
||||
auto j = json::parse(message->get_payload(), nullptr, false);
|
||||
if (message->get_opcode() != websocketpp::frame::opcode::TEXT ||
|
||||
!j.is_object())
|
||||
return Drop(h);
|
||||
if (!c.authorized) {
|
||||
if (j.size() != 1 || !j.contains("ticket") || !j["ticket"].is_string())
|
||||
return Drop(h);
|
||||
auto ticket = tickets.find(j["ticket"].get<std::string>());
|
||||
if (ticket == tickets.end())
|
||||
return Drop(h);
|
||||
const auto grant = std::move(ticket->second);
|
||||
tickets.erase(ticket);
|
||||
if (!connected || grant.expires <= Clock::now() || viewers >= 8 ||
|
||||
(c.origin != grant.grant.origin &&
|
||||
!(grant.grant.apps && AppOrigin(c.origin))))
|
||||
return Drop(h);
|
||||
c.authorized = true;
|
||||
c.view = grant.grant.view;
|
||||
c.view_generation = grant.grant.view_generation;
|
||||
c.session = grant.grant.session;
|
||||
++viewers;
|
||||
// Also prompts a raw viewer to receive the current fresh picture.
|
||||
key_requested = true;
|
||||
return;
|
||||
}
|
||||
if (j.value("type", "") == "keyframe" && j.size() == 1 && !decoded) {
|
||||
c.waiting_key = true;
|
||||
RequestKey();
|
||||
return;
|
||||
}
|
||||
if (j.value("type", "") != "ack" || j.size() != 3 ||
|
||||
!j.contains("generation") || !j["generation"].is_number_unsigned() ||
|
||||
!j.contains("sequence") || !j["sequence"].is_number_unsigned() ||
|
||||
c.receipts.empty() ||
|
||||
j["generation"] != c.receipts.front().generation ||
|
||||
j["sequence"] != c.receipts.front().sequence)
|
||||
return Drop(h);
|
||||
c.bytes -= c.receipts.front().bytes;
|
||||
outstanding -= c.receipts.front().bytes;
|
||||
c.receipts.pop_front();
|
||||
}
|
||||
void Maintain() {
|
||||
timer = server.set_timer(
|
||||
250, [this](const websocketpp::lib::error_code &error) {
|
||||
if (error)
|
||||
return;
|
||||
const auto now = Clock::now();
|
||||
for (auto it = tickets.begin(); it != tickets.end();)
|
||||
if (it->second.expires <= now)
|
||||
it = tickets.erase(it);
|
||||
else
|
||||
++it;
|
||||
std::vector<Handle> close;
|
||||
for (auto &[h, c] : clients) {
|
||||
if ((!c.authorized && now - c.opened >= 3s) ||
|
||||
(!c.receipts.empty() && now - c.receipts.front().sent >= 10s)) {
|
||||
close.push_back(h);
|
||||
} else if (c.authorized && now - c.pinged >= 10s) {
|
||||
c.pinged = now;
|
||||
websocketpp::lib::error_code ec;
|
||||
server.ping(h, "", ec);
|
||||
if (ec)
|
||||
close.push_back(h);
|
||||
}
|
||||
}
|
||||
for (auto h : close)
|
||||
Drop(h);
|
||||
Maintain();
|
||||
});
|
||||
}
|
||||
void Enqueue(Packet p) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (p.raw) {
|
||||
dropped += queue.size();
|
||||
queue.clear();
|
||||
queued_bytes = 0;
|
||||
} else {
|
||||
if (queue.size() >= 60 || queued_bytes + p.Size() > kMaxQueued) {
|
||||
dropped += queue.size();
|
||||
queue.clear();
|
||||
queued_bytes = 0;
|
||||
wait_key = true;
|
||||
key_requested = true;
|
||||
}
|
||||
if (wait_key && !p.Key()) {
|
||||
++dropped;
|
||||
return;
|
||||
}
|
||||
wait_key = false;
|
||||
}
|
||||
queued_bytes += p.Size();
|
||||
queue.push_back(std::move(p));
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
server.get_io_service().post([this] { Drain(); });
|
||||
}
|
||||
}
|
||||
void Drain() {
|
||||
Packet p;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (queue.empty()) {
|
||||
scheduled = false;
|
||||
return;
|
||||
}
|
||||
p = std::move(queue.front());
|
||||
queued_bytes -= p.Size();
|
||||
queue.pop_front();
|
||||
}
|
||||
if (connected && p.Generation() == generation && bool(p.raw) == decoded)
|
||||
Publish(p);
|
||||
// Let authorization, reads, and timers run between large pictures.
|
||||
server.get_io_service().post([this] { Drain(); });
|
||||
}
|
||||
void Publish(const Packet &p) {
|
||||
std::vector<Handle> ready;
|
||||
for (auto &[h, c] : clients) {
|
||||
if (!c.authorized)
|
||||
continue;
|
||||
// ACKs are flow-control hints, not proof that a client read the socket.
|
||||
// Also bound the actual send queue against forged early receipts.
|
||||
const auto buffered = server.get_con_from_hdl(h)->get_buffered_amount();
|
||||
if ((decoded && (!c.receipts.empty() || buffered > 0)) ||
|
||||
(!decoded && (c.receipts.size() >= 8 || c.bytes >= 1024 * 1024 ||
|
||||
buffered >= 1024 * 1024))) {
|
||||
++dropped;
|
||||
c.waiting_key = true;
|
||||
if (!decoded)
|
||||
RequestKey();
|
||||
continue;
|
||||
}
|
||||
if (!decoded && c.waiting_key && !p.Key()) {
|
||||
RequestKey();
|
||||
continue;
|
||||
}
|
||||
ready.push_back(h);
|
||||
}
|
||||
if (ready.empty())
|
||||
return;
|
||||
// Prepare once and share the immutable WebSocket message across viewers.
|
||||
// No length-prefixed pipe, Node Buffer, or per-viewer payload copy.
|
||||
auto message =
|
||||
server.get_con_from_hdl(ready.front())
|
||||
->get_message(websocketpp::frame::opcode::BINARY, p.Size());
|
||||
auto &bytes = message->get_raw_payload();
|
||||
Number(bytes, decoded ? 0x43444e31 : 0x43445631, 4);
|
||||
Number(bytes, p.Generation(), 4);
|
||||
Number(bytes, p.Sequence(), 4);
|
||||
const auto time =
|
||||
p.raw ? std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
p.raw->queued.time_since_epoch())
|
||||
.count()
|
||||
: p.encoded.timestamp_us;
|
||||
Number(bytes, time, 8);
|
||||
Number(bytes, p.Key() ? 1 : 0, 4);
|
||||
if (p.raw) {
|
||||
Number(bytes, p.raw->width, 4);
|
||||
Number(bytes, p.raw->height, 4);
|
||||
message->append_payload(p.raw->nv12.data(), p.raw->nv12.size());
|
||||
} else
|
||||
message->append_payload(p.encoded.data.data(), p.encoded.data.size());
|
||||
message->set_header(websocketpp::frame::prepare_header(
|
||||
websocketpp::frame::basic_header(websocketpp::frame::opcode::BINARY,
|
||||
bytes.size(), true, false),
|
||||
websocketpp::frame::extended_header(bytes.size())));
|
||||
message->set_prepared(true);
|
||||
for (auto h : ready) {
|
||||
auto &c = clients.at(h);
|
||||
websocketpp::lib::error_code ec;
|
||||
server.send(h, message, ec);
|
||||
if (ec) {
|
||||
Drop(h);
|
||||
continue;
|
||||
}
|
||||
c.waiting_key = false;
|
||||
c.bytes += bytes.size();
|
||||
outstanding += bytes.size();
|
||||
c.receipts.push_back({uint32_t(p.Generation()), uint32_t(p.Sequence()),
|
||||
bytes.size(), Clock::now()});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
VideoServer::VideoServer() : state_(std::make_unique<State>()) {}
|
||||
VideoServer::~VideoServer() = default;
|
||||
std::string VideoServer::Url() const {
|
||||
return "ws://" + state_->authority + "/api/video";
|
||||
}
|
||||
void VideoServer::Bind(uint64_t generation, bool decoded, bool connected) {
|
||||
auto &s = *state_;
|
||||
// Only the automation owner calls Bind; the I/O thread reads these values.
|
||||
s.Call([&s, generation, decoded, connected] {
|
||||
if (s.generation == generation && s.decoded == decoded &&
|
||||
s.connected == connected)
|
||||
return;
|
||||
s.Revoke("");
|
||||
s.generation = generation;
|
||||
s.decoded = decoded;
|
||||
s.connected = connected;
|
||||
s.key_requested = false;
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.queue.clear();
|
||||
s.queued_bytes = 0;
|
||||
s.wait_key = true;
|
||||
});
|
||||
}
|
||||
void VideoServer::Grant(VideoGrant grant) {
|
||||
if (!Token(grant.ticket) || !Token(grant.view) || !Token(grant.session) ||
|
||||
!grant.view_generation || !BrowserOrigin(grant.origin))
|
||||
throw std::invalid_argument("INVALID_VIDEO_GRANT");
|
||||
auto &s = *state_;
|
||||
s.Call([&s, grant = std::move(grant)] {
|
||||
if (!s.connected)
|
||||
throw std::invalid_argument("NOT_READY");
|
||||
for (auto it = s.tickets.begin(); it != s.tickets.end();)
|
||||
if (it->second.expires <= Clock::now())
|
||||
it = s.tickets.erase(it);
|
||||
else
|
||||
++it;
|
||||
if (s.tickets.size() >= 32)
|
||||
throw std::invalid_argument("VIDEO_LIMIT");
|
||||
// A replaced view cannot retain a socket or consume an old grant.
|
||||
bool changed = false;
|
||||
for (const auto &[h, c] : s.clients)
|
||||
changed |=
|
||||
c.view == grant.view && (c.view_generation != grant.view_generation ||
|
||||
c.session != grant.session);
|
||||
for (const auto &[ticket, t] : s.tickets)
|
||||
changed |= t.grant.view == grant.view &&
|
||||
(t.grant.view_generation != grant.view_generation ||
|
||||
t.grant.session != grant.session);
|
||||
if (changed)
|
||||
s.Revoke(grant.view);
|
||||
if (!s.tickets
|
||||
.emplace(grant.ticket, State::Ticket{grant, Clock::now() + 15s})
|
||||
.second)
|
||||
throw std::invalid_argument("INVALID_VIDEO_GRANT");
|
||||
});
|
||||
}
|
||||
void VideoServer::Revoke(const std::string &view) {
|
||||
state_->Call([&] { state_->Revoke(view); });
|
||||
}
|
||||
bool VideoServer::Enabled() const { return state_->viewers.load() > 0; }
|
||||
bool VideoServer::TakeKeyframeRequest() {
|
||||
return state_->key_requested.exchange(false);
|
||||
}
|
||||
void VideoServer::Submit(std::shared_ptr<const DecodedFrame> frame) {
|
||||
if (!frame || !frame->width || !frame->height || (frame->width & 1) ||
|
||||
(frame->height & 1) || frame->width > 8192 || frame->height > 8192 ||
|
||||
uint64_t(frame->width) * frame->height > 16777216 ||
|
||||
frame->nv12.size() != size_t(frame->width) * frame->height * 3 / 2)
|
||||
throw std::invalid_argument("INVALID_VIDEO_FRAME");
|
||||
if (Enabled())
|
||||
state_->Enqueue({std::move(frame), {}});
|
||||
}
|
||||
void VideoServer::Submit(EncodedVideoFrame frame) {
|
||||
if (frame.data.empty() || frame.data.size() > kMaxEncoded)
|
||||
throw std::invalid_argument("INVALID_VIDEO_FRAME");
|
||||
if (Enabled())
|
||||
state_->Enqueue({{}, std::move(frame)});
|
||||
}
|
||||
json VideoServer::Status() const {
|
||||
const auto &s = *state_;
|
||||
std::lock_guard lock(s.mutex);
|
||||
return {{"transport", "loopback-websocket/1"},
|
||||
{"viewers", s.viewers.load()},
|
||||
{"queued", s.queue.size()},
|
||||
{"queued_bytes", s.queued_bytes},
|
||||
{"unacknowledged_bytes", s.outstanding.load()},
|
||||
{"dropped", s.dropped.load()}};
|
||||
}
|
||||
} // namespace crossdesk::cli
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-03
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _VIDEO_SERVER_H_
|
||||
#define _VIDEO_SERVER_H_
|
||||
|
||||
#include <controller_session.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
|
||||
struct VideoGrant {
|
||||
std::string ticket, view, session, origin;
|
||||
uint64_t view_generation = 0;
|
||||
bool apps = false;
|
||||
};
|
||||
|
||||
// Owns the loopback listener and its I/O thread. JSON-RPC carries only grants
|
||||
// and diagnostics; encoded and decoded pictures stay in the native process.
|
||||
class VideoServer {
|
||||
public:
|
||||
VideoServer();
|
||||
~VideoServer();
|
||||
VideoServer(const VideoServer &) = delete;
|
||||
VideoServer &operator=(const VideoServer &) = delete;
|
||||
|
||||
std::string Url() const;
|
||||
void Bind(uint64_t generation, bool decoded, bool connected);
|
||||
void Grant(VideoGrant grant);
|
||||
void Revoke(const std::string &view = "");
|
||||
bool Enabled() const;
|
||||
bool TakeKeyframeRequest();
|
||||
void Submit(std::shared_ptr<const DecodedFrame> frame);
|
||||
void Submit(EncodedVideoFrame frame);
|
||||
nlohmann::json Status() const;
|
||||
|
||||
private:
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::cli
|
||||
|
||||
#endif
|
||||
+7
-3
@@ -4,6 +4,8 @@ local terminal_dir = path.join(source_dir, "libs/terminal")
|
||||
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
|
||||
|
||||
add_requires("spdlog 1.14.1", {system = false})
|
||||
add_requires("asio 1.32.0", "websocketpp 0.8.2", {system = false})
|
||||
add_requires("libpng v1.6.59", {system = false, configs = {shared = false}})
|
||||
if is_plat("windows") then
|
||||
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
||||
end
|
||||
@@ -11,7 +13,7 @@ end
|
||||
target("crossdesk_controller")
|
||||
set_kind("static")
|
||||
add_deps("minirtc", "crossdesk_wire")
|
||||
add_packages("spdlog")
|
||||
add_packages("spdlog", "libpng", "libyuv")
|
||||
add_includedirs(path.join(controller_dir, "include"),
|
||||
path.join(minirtc_dir, "src/api"), {public = true})
|
||||
add_includedirs(path.join(minirtc_dir, "src/log"))
|
||||
@@ -32,12 +34,14 @@ target_end()
|
||||
|
||||
target("crossdesk-cli")
|
||||
set_kind("binary")
|
||||
set_version("0.1.6")
|
||||
set_version("0.2.0")
|
||||
add_deps("crossdesk_terminal")
|
||||
add_files("main.cpp")
|
||||
add_files("agent.cpp", "video_server.cpp")
|
||||
add_packages("asio", "websocketpp")
|
||||
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
|
||||
if is_plat("windows") then
|
||||
add_syslinks("advapi32", "user32")
|
||||
add_syslinks("advapi32", "user32", "ws2_32")
|
||||
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
||||
elseif is_plat("linux") then
|
||||
add_syslinks("rt")
|
||||
|
||||
Reference in New Issue
Block a user